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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"scripts": {
"generate:assets": "node scripts/generate-asset-manifest.js",
"llms:generate": "node scripts/generate-llms-files.mjs",
"map:generate": "node scripts/generate-world-map-dots.mjs",
"centroids:generate": "node scripts/generate-country-centroids.mjs",
"release:manifest": "node scripts/generate-release-manifest.mjs",
Comment on lines 55 to 60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run required post-edit package checks before merge.

Because package.json changed, please run and confirm:

  • corepack yarn install (sync yarn.lock)
  • corepack yarn blotter:check

As per coding guidelines, "Run corepack yarn install to keep yarn.lock in sync when package.json changes." and "{CHANGELOG.md,package.json}: If CHANGELOG.md or package version changes, run yarn blotter:check."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 55 - 60, Package.json was modified so before
merging, run corepack yarn install to sync yarn.lock and then run corepack yarn
blotter:check to validate changelog/version rules; ensure you execute these in
the repo root and commit any updated yarn.lock or blotter-fix outputs so the
changes to the "scripts" block (e.g.,
"generate:assets","llms:generate","map:generate","centroids:generate","release:manifest")
are accompanied by an updated lockfile and passing blotter check.

"release:manifest:keygen": "node scripts/generate-release-manifest-keypair.mjs",
"sync:directories": "node scripts/sync-directories.js",
Expand Down
74 changes: 74 additions & 0 deletions scripts/generate-country-centroids.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Generates src/data/country-centroids.ts: ISO 3166-1 alpha-2 -> approximate
// country centroid, used to place P2P peer markers at the center of the country
// already shown as the peer's flag. Run with `yarn centroids:generate`.
//
// Peer country itself is derived offline (see lib/peer-geo getApproximateCountryCode)
// so no peer IP is ever sent to a geolocation API; this only supplies the static
// "where is country X" centroids the markers snap to.

import { writeFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '..');
const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/country-centroids.ts');

// Public-domain ISO country centroids (country,latitude,longitude,name).
// Override with COUNTRY_CENTROIDS_CSV_URL.
const CSV_URL =
process.env.COUNTRY_CENTROIDS_CSV_URL ?? 'https://raw.githubusercontent.com/google/dspl/master/samples/google/canonical/countries.csv';

Comment on lines +17 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Centroid source is stale for current ISO alpha-2 coverage.

The generated dataset includes retired code an (Netherlands Antilles) and misses modern split codes like cw, sx, and bq, which can cause centroid snapping gaps for valid country lookups. Please switch to a maintained ISO centroid source or add an explicit override map for modern replacements before writing the module.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/generate-country-centroids.mjs` around lines 17 - 21, The CSV source
in CSV_URL (scripts/generate-country-centroids.mjs) is stale and includes
retired codes like "an" while missing modern ISO alpha-2 codes (e.g., "cw",
"sx", "bq"); update CSV_URL to point to a maintained centroid source or, if you
prefer to keep the current CSV, add an override map (e.g., COUNTRY_REPLACEMENTS
or centroidOverrides) in generate-country-centroids.mjs that remaps retired
codes to current ones and adds entries for missing modern codes before the code
that parses/writes the module (ensure the override is applied in the parsing
function that consumes CSV_URL and before the module writer function).

const log = (message) => process.stdout.write(`[generate-country-centroids] ${message}\n`);

const fetchCsv = async (url) => {
log(`fetching ${url}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`failed to fetch centroid CSV: ${response.status} ${response.statusText}`);
return response.text();
};

const round = (value) => Math.round(value * 100) / 100;

const parseCentroids = (csv) => {
const centroids = {};
for (const line of csv.trim().split('\n').slice(1)) {
// Only the first three columns are needed; the trailing name may contain commas.
const [code, lat, lon] = line.split(',');
const iso = code?.trim().toLowerCase();
const latNum = Number(lat);
const lonNum = Number(lon);
if (!/^[a-z]{2}$/.test(iso ?? '') || !Number.isFinite(latNum) || !Number.isFinite(lonNum)) continue;
centroids[iso] = { lat: round(latNum), lon: round(lonNum) };
}
return centroids;
};

const renderModule = (centroids) => {
const entries = Object.keys(centroids)
.sort()
.map((iso) => ` ${iso}: { lat: ${centroids[iso].lat}, lon: ${centroids[iso].lon} },`)
.join('\n');
return `// GENERATED FILE — do not edit by hand.
// Source: Google DSPL canonical countries.csv (public domain), via
// scripts/generate-country-centroids.mjs. Run \`yarn centroids:generate\` to refresh.
//
// ISO 3166-1 alpha-2 (lowercase) -> approximate country centroid, used to place
// P2P peer markers at the center of the country shown as the peer's flag.
export const COUNTRY_CENTROIDS: Record<string, { lat: number; lon: number }> = {
${entries}
};
`;
};

const main = async () => {
const csv = await fetchCsv(CSV_URL);
const centroids = parseCentroids(csv);
writeFileSync(OUTPUT_PATH, renderModule(centroids));
log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${Object.keys(centroids).length} countries)`);
};

main().catch((error) => {
process.stderr.write(`[generate-country-centroids] ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
144 changes: 144 additions & 0 deletions scripts/generate-world-map-dots.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Generates src/data/world-map-dots.ts: a compact dotted-world-map backdrop for
// the P2P stats panel, rasterized from Natural Earth 1:110m land polygons
// (public domain). Run with `yarn map:generate`.
//
// The map is decorative ("approximate locations"), so we only need a recognizable
// land/sea mask. We rasterize the real land polygons onto a regular lon/lat grid
// and pack the result into a 1-bit-per-cell bitmap (base64). At runtime the panel
// expands set bits into square dots using the same equirectangular projection the
// peer markers use, so dots and markers stay aligned without shipping a geo lib.

import { Buffer } from 'node:buffer';
import { writeFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '..');
const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/world-map-dots.ts');

// Natural Earth 1:110m land (public domain). Override with WORLD_MAP_GEOJSON_URL.
const GEOJSON_URL =
process.env.WORLD_MAP_GEOJSON_URL ??
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_land.geojson';

// Equirectangular grid. STEP is the cell size in degrees; smaller = finer/more
// dots. The latitude band excludes Antarctica and trims the very top of the map.
const STEP = 2;
const LON_MIN = -180;
const LAT_MAX = 84;
const LAT_MIN = -56;
const COLS = Math.round((180 - LON_MIN) / STEP);
const ROWS = Math.round((LAT_MAX - LAT_MIN) / STEP);

const log = (message) => process.stdout.write(`[generate-world-map-dots] ${message}\n`);

const fetchGeoJson = async (url) => {
log(`fetching ${url}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`failed to fetch land GeoJSON: ${response.status} ${response.statusText}`);
return response.json();
};

// Flatten Polygon/MultiPolygon features into rings plus a bounding box for a fast
// reject before the per-edge ray cast.
const collectPolygons = (geojson) => {
const polygons = [];
for (const feature of geojson.features ?? []) {
const geometry = feature.geometry;
if (!geometry) continue;
const coordinateSets =
geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.type === 'MultiPolygon' ? geometry.coordinates : [];
for (const rings of coordinateSets) {
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
for (const ring of rings) {
for (const [lon, lat] of ring) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
}
polygons.push({ bbox: [minLon, minLat, maxLon, maxLat], rings });
}
}
return polygons;
};

// Even-odd ray casting across every ring of a polygon, so interior holes (lakes)
// correctly read as sea. Natural Earth cuts geometry at the antimeridian, so no
// polygon wraps past +/-180 and a plain lon-space ray cast is sound.
const isInsidePolygon = (lon, lat, rings) => {
let inside = false;
for (const ring of rings) {
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
}
return inside;
};

const isLand = (lon, lat, polygons) => {
for (const { bbox, rings } of polygons) {
if (lon < bbox[0] || lon > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue;
if (isInsidePolygon(lon, lat, rings)) return true;
}
return false;
};

const rasterize = (polygons) => {
const bits = new Uint8Array(Math.ceil((COLS * ROWS) / 8));
let landCount = 0;
for (let row = 0; row < ROWS; row++) {
const lat = LAT_MAX - (row + 0.5) * STEP;
for (let col = 0; col < COLS; col++) {
const lon = LON_MIN + (col + 0.5) * STEP;
if (isLand(lon, lat, polygons)) {
const index = row * COLS + col;
bits[index >> 3] |= 1 << (7 - (index & 7));
landCount++;
}
}
}
return { bits, landCount };
};

const renderModule = (base64) =>
`// GENERATED FILE — do not edit by hand.
// Source: Natural Earth 1:110m land (public domain), rasterized by
// scripts/generate-world-map-dots.mjs. Run \`yarn map:generate\` to refresh.
//
// Row-major land/sea grid for the P2P stats world map. \`bitmap\` is a base64
// 1-bit-per-cell mask (MSB first, 1 = land). Cell (col,row) centers on
// lon = lonMin + (col + 0.5) * step, lat = latMax - (row + 0.5) * step.
export const WORLD_MAP_DOTS = {
step: ${STEP},
lonMin: ${LON_MIN},
latMax: ${LAT_MAX},
cols: ${COLS},
rows: ${ROWS},
bitmap: '${base64}',
} as const;
`;

const main = async () => {
const geojson = await fetchGeoJson(GEOJSON_URL);
const polygons = collectPolygons(geojson);
log(`rasterizing ${polygons.length} land polygons onto a ${COLS}x${ROWS} grid (step ${STEP}°)`);
const { bits, landCount } = rasterize(polygons);
const base64 = Buffer.from(bits).toString('base64');
writeFileSync(OUTPUT_PATH, renderModule(base64));
log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${landCount} land cells, ${base64.length} base64 chars)`);
};

main().catch((error) => {
process.stderr.write(`[generate-world-map-dots] ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ describe('P2PStatsSettings', () => {
};
testState.rpcSettings = { state: 'disconnected' };
testState.setAccountMock.mockReset().mockResolvedValue(undefined);
// Default: own-IP country lookups (api.country.is) resolve offline so browser
// stats tests never hit the network. Individual tests can override this stub.
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ country: 'US', ip: '147.75.84.175' }),
}),
);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
Expand All @@ -68,6 +77,7 @@ describe('P2PStatsSettings', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

it('renders browser libp2p stats from the active PKC client', async () => {
Expand Down Expand Up @@ -97,7 +107,7 @@ describe('P2PStatsSettings', () => {
status: 'open',
},
],
getMultiaddrs: () => ['/ip4/127.0.0.1/tcp/4001'],
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
getPeers: () => ['peer-1', 'peer-2'],
metrics: {
toJSON: () => ({
Expand Down Expand Up @@ -134,6 +144,15 @@ describe('P2PStatsSettings', () => {
const rows = getStatRows();
const connectedPeers = container.querySelector('[data-testid="connected-peers"]');
expect(container.textContent).toContain('Leeching');
expect(container.textContent).toContain('want to seed');
const seederLink = container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]');
expect(seederLink).not.toBeNull();
expect(seederLink?.textContent).toBe('want to seed?');
expect(rows.get('Your IP')).toContain('147.75.84.175');
// The own IP is geolocated accurately (per-IP lookup), not via the coarse peer guess.
expect(fetch).toHaveBeenCalledWith('https://api.country.is/147.75.84.175', expect.objectContaining({ signal: expect.any(AbortSignal) }));
const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP'));
expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull();
expect(container.textContent).not.toContain('browser Helia');
expect(container.textContent).not.toContain('seed mode');
expect(container.textContent).not.toContain('status');
Expand All @@ -143,6 +162,14 @@ describe('P2PStatsSettings', () => {
expect(rows.has('connections')).toBe(false);
expect(rows.has('Listen addresses')).toBe(false);
expect(rows.has('p2p_stats_updated')).toBe(true);
const tableRows = Array.from(container.querySelectorAll('tr'));
const rowTexts = tableRows.map((row) => row.textContent ?? '');
const dataSentIndex = rowTexts.findIndex((text) => text.includes('Data sent'));
const updatedIndex = rowTexts.findIndex((text) => text.includes('p2p_stats_updated'));
const connectedPeersIndex = rowTexts.findIndex((text) => text.includes('Connected peers'));
expect(dataSentIndex).toBeGreaterThanOrEqual(0);
expect(updatedIndex).toBeGreaterThan(dataSentIndex);
expect(connectedPeersIndex).toBeGreaterThan(updatedIndex);
expect(container.textContent).toContain('self-peer');
expect(container.textContent).toContain('Peer ID');
expect(container.textContent).toContain('Data received');
Expand Down Expand Up @@ -183,7 +210,7 @@ describe('P2PStatsSettings', () => {
},
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
Expand Down Expand Up @@ -217,7 +244,7 @@ describe('P2PStatsSettings', () => {
_helia: {
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
Expand All @@ -236,6 +263,55 @@ describe('P2PStatsSettings', () => {
expect(rows.get('Data sent')).toBe('0 B');
});

it('falls back to the browser node public endpoint when Helia exposes no public address', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }),
}),
);
testState.account = {
...testState.account,
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
_helia: {
libp2p: {
getConnections: () => [
{
localAddr: { toString: () => '/ip4/127.0.0.1/tcp/4001/ws' },
remoteAddr: { toString: () => '/ip4/127.0.0.1/tcp/4001/ws/p2p/peer-1' },
remotePeer: { toString: () => 'peer-1' },
},
],
getMultiaddrs: () => [],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
},
},
},
},
},
};

await renderSettings(false);
await act(async () => Promise.resolve());

const rows = getStatRows();
expect(rows.get('Your IP')).toContain('2001:4860:4860::8888');
expect(rows.get('Your IP')).not.toContain('unknown');
const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP'));
expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull();
expect(fetch).toHaveBeenCalledWith('https://api.country.is', expect.objectContaining({ signal: expect.any(AbortSignal) }));
});

it('reports seeding only when browser Helia can add and publish provider records', async () => {
testState.account = {
...testState.account,
Expand All @@ -253,7 +329,7 @@ describe('P2PStatsSettings', () => {
_helia: {
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
Expand All @@ -275,6 +351,7 @@ describe('P2PStatsSettings', () => {
await act(async () => Promise.resolve());

expect(container.textContent).toContain('Seeding');
expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull();
expect(container.textContent).not.toContain('seed mode');
});
});
Loading
Loading