feat(share): short /share/<code> links with a compact versioned view format (#1418)

* feat(share): add compact versioned share-view codec

Add a standalone codec for `#s=<base64url>` share links: a version byte
followed by protobuf-style tag-length-value fields. Settings equal to
their default are omitted and refilled from the same table on decode;
unknown fields are skipped by wire type, so new fields never break
older apps. Views are a place (camera) or a thing (an aircraft,
military aircraft, vessel or satellite to follow, with camera offset
and last-known fix); an unknown kind falls back to the camera.

The decoder bounds its input and rejects malformed payloads with a
reason instead of throwing. A converter maps the long form's parsed
state to and from the codec state; the long form is unchanged.

* fix(share): tighten codec decoding after review

Unknown detection mode and allocation names now fall back to their
defaults instead of reaching the lenient long-form migration. Future
panel values and style-parameter tokens are skipped rather than applied
or rejected, and layer options are accepted only under their owner
layer. Wrap longitudes before scaling, normalize negative zero, reuse
the layer-state tracking ID grammar, and align the format doc with the
decoder.

* feat(share): let a host share the link its own way

ShareLinkManager.buildLink() returns the current share link without
copying it, and copyLink() now copies exactly that. The share button
passes the link to an optional services.shareLink(url), which resolves
to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast
and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the
service the button copies as before.

* feat(share): open and make short /share/<code> links

The share button now makes `<app>/share/<code>` links with the compact
share-view code, and the app opens them at start-up through the same
restore pipeline as long links. Once the app next writes the address, the
share path is replaced (no new history entry) by the app's own address and
the live long-form hash, so a reload follows the live view. A host can pass
the code as `initialShareCode` instead. A code that fails to decode restores
nothing and shows "This share link can't be opened".

The code gains a trailing CRC-8 check byte, so any single mistyped
character is rejected instead of opening a different view. Thing views no
longer store a camera: it is derived from the entity's last-known fix and
the follow offset on a sphere, the entity kind is implied by the id's
field, and the followed entity's layer bit is implied; an aircraft followed
from the app's own camera is a 42-character code. The `#s=` hash form is
gone.

While the camera follows an aircraft, military aircraft or satellite, the
link is a thing view with the camera's offset and the entity's position.
Opening one turns the layer on and follows the entity through the existing
shared-follow restore, then swings the camera round to the shared offset; a
vessel view selects the vessel once the vessel feed has it. If the entity
is not reporting within its window, the view stays at its last-known fix
and says so. The panel and style-parameter tables now come from the codec,
and a host share hook that returns a plain value or throws still gets a
toast.

* fix(share): encode style settings against the style's own look

Settings a style applies when selected (bloom, sharpen, HUD, detection,
celestial ring and the style's shader parameters) now default, per field,
to what that style applies in a fresh session, read from the tables the
app applies (the first-load baseline, the style presets and the shader
defaults). The encoder compares against the view's style and the decoder
fills omitted settings from the decoded style, so a view that keeps its
style's look costs only its camera and style. The previous defaults did not
match a fresh session (sharpen, HUD and detection were off), so every link
paid for them, and a style's own shader parameters were always written.

A test pins the per-style defaults as frozen v1 meanings. A followed
aircraft in Noir is now 44 characters and the real long-link example 39.

* docs: changelog for #1418
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-26 15:31:51 -07:00
1 parent fefd5cdcf1
commit da02fd8645
28 files changed
+5605 -97

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
/**
* What a share link's thing view follows, and where its camera is: the
* entity kinds and the follow-offset geometry of the share format
* (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code,
* default and formula here is permanent.
*
* Pure and dependency-free, so the app's follow camera and the codec
* (viewCodec.js) share one definition.
*/
const freezeAll = (rows) =>
Object.freeze(rows.map((row) => Object.freeze(row)));
/**
* Things a view can follow. `code` is permanent and is also the number of the
* thing-message field that carries this kind's id, so the kind costs nothing
* on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO
* address or a decimal number); any other identifier travels as text.
* `tracking` names the layer-state slot that holds the same selection in the
* long form.
*/
export const SHARE_VIEW_ENTITY_KINDS = freezeAll([
{
code: 1,
kind: 'aircraft',
layerId: 'flights',
idForm: 'hex24',
tracking: 'flights',
},
{
code: 2,
kind: 'military_aircraft',
layerId: 'military',
idForm: 'hex24',
tracking: 'military',
},
{
code: 3,
kind: 'vessel',
layerId: 'ais-live-vessels',
idForm: 'decimal',
tracking: null,
},
{
code: 4,
kind: 'satellite',
layerId: 'satellites',
idForm: 'decimal',
tracking: 'satellites',
},
]);
/**
* The frozen meaning of an omitted follow-offset field. An offset whose
* fields all equal these is written as nothing, so it decodes as these.
*/
export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({
range: 2000,
heading: 0,
pitch: -35,
});
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
/**
* The camera's offset from a followed entity in the entity's local east,
* north and up metres: the camera looks along (heading, pitch) at the entity
* from `range` metres away. Heading is clockwise from north; a negative pitch
* looks down, so the camera sits above the entity.
*/
export function followOffsetToEnu({ range, heading, pitch }) {
const horizontal = range * Math.cos(pitch * RAD);
return {
east: -horizontal * Math.sin(heading * RAD),
north: -horizontal * Math.cos(heading * RAD),
up: -range * Math.sin(pitch * RAD),
};
}
/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */
export function followOffsetFromEnu({ east, north, up }) {
const range = Math.hypot(east, north, up);
if (!(range > 0)) return null;
return {
range: Math.round(range),
heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360,
pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD),
};
}
/** Earth-centred position of (lat, lon, alt) on the format's sphere. */
function toEcef(lat, lon, alt) {
const r = EARTH_RADIUS_M + alt;
const cosLat = Math.cos(lat * RAD);
return [
r * cosLat * Math.cos(lon * RAD),
r * cosLat * Math.sin(lon * RAD),
r * Math.sin(lat * RAD),
];
}
/** The local east, north and up unit vectors at (lat, lon). */
function enuBasis(lat, lon) {
const sinLat = Math.sin(lat * RAD);
const cosLat = Math.cos(lat * RAD);
const sinLon = Math.sin(lon * RAD);
const cosLon = Math.cos(lon * RAD);
return {
east: [-sinLon, cosLon, 0],
north: [-sinLat * cosLon, -sinLat * sinLon, cosLat],
up: [cosLat * cosLon, cosLat * sinLon, sinLat],
};
}
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
/**
* Where the camera sits while following an entity at `fix` from `offset`,
* and how it looks back at the entity, on a sphere of radius 6 371 000 m. A
* thing view with a fix stores no camera of its own: this is its camera, and
* the place a decoder falls back to. Pure, and computed only from values at
* the format's precision, so encoder and decoder agree.
*/
export function followCameraFor(fix, offset) {
const local = followOffsetToEnu(offset);
const at = toEcef(fix.lat, fix.lon, fix.alt);
const basis = enuBasis(fix.lat, fix.lon);
const camera = [0, 1, 2].map(
(i) =>
at[i] +
local.east * basis.east[i] +
local.north * basis.north[i] +
local.up * basis.up[i],
);
const horizontal = Math.hypot(camera[0], camera[1]);
const lat = Math.atan2(camera[2], horizontal) / RAD;
const lon = Math.atan2(camera[1], camera[0]) / RAD;
const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M;
// The view direction, camera to entity, in the camera's own local frame.
const view = [0, 1, 2].map((i) => at[i] - camera[i]);
const own = enuBasis(lat, lon);
const length = Math.hypot(...view);
if (!(length > 0))
return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 };
return {
lat,
lon,
alt,
heading:
(Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360,
pitch:
Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD,
roll: 0,
};
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Conversion between the long share-link form's parsed state (what
* `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the
* compact share-view state of viewCodec.js.
*
* The two use the same field names, so the conversion is small: the compact
* form adds `view`, and the long form keeps a followed entity as a tracking
* selection inside the layer options instead.
*/
import {
SHARE_TRACKING_RESTORE_POLICIES,
createDefaultLayerState,
normalizeLayerState,
} from '../data/layerState.js';
import {
SHARE_VIEW_ENTITY_KINDS,
normalizeShareViewState,
} from './viewCodec.js';
/** Fields of the long form's parsed state, in its own order. */
export const LEGACY_SHARE_STATE_KEYS = Object.freeze([
'lat',
'lon',
'alt',
'heading',
'pitch',
'roll',
'style',
'styleParams',
'bloom',
'sharpen',
'bloomIntensity',
'bloomVersion',
'sharpenIntensity',
'hudVariant',
'hudVisible',
'detectionMode',
'detectionDensity',
'detectionAllocation',
'detectionFadePct',
'detectionOutsideOpacityPct',
'celestialRing',
'scopeEnabled',
'scopeFeatherPct',
'scopeTerminusPct',
'mapStack',
'layerState',
'layerStateInvalid',
'panelState',
'sharedAtMs',
]);
/**
* A compact share-view state from a parsed long-form state. A tracking
* selection in the layer options becomes a thing view with no offset or
* last-known fix, since the long form records neither. Restore bookkeeping
* (`restoreAuthority`, `layerStateInvalid`) is not part of a view.
*/
export function fromLegacyShareState(parsed) {
if (!parsed || typeof parsed !== 'object') return null;
const picked = {};
for (const key of LEGACY_SHARE_STATE_KEYS) {
if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key))
picked[key] = parsed[key];
}
return normalizeShareViewState(picked);
}
/**
* The parsed long-form state for a compact share-view state, ready for
* `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer
* set when the view has none), and a followed aircraft, military aircraft or
* satellite goes back into its layer's tracking selection. The long form
* cannot express the rest of a thing view (a vessel, the follow offset, the
* last-known fix); callers that need them read `state.view` directly.
*/
export function toLegacyShareState(input) {
const state = normalizeShareViewState(input);
const entry =
state.view.kind === 'thing'
? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind)
: null;
let layerState =
entry && !state.layerState
? normalizeLayerState({
...createDefaultLayerState(),
enabledLayerIds: [entry.layerId],
})
: state.layerState;
const policy = entry?.tracking
? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking]
: null;
if (
layerState &&
policy &&
layerState.enabledLayerIds.includes(entry.layerId)
) {
const id =
entry.kind === 'satellite'
? Number(state.view.entity.id)
: state.view.entity.id;
layerState = normalizeLayerState({
...layerState,
options: {
...layerState.options,
[policy.optionOwner]: {
...layerState.options[policy.optionOwner],
[policy.optionKey]: id,
},
},
});
}
const legacy = {};
for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key];
legacy.layerState = layerState;
legacy.layerStateInvalid = false;
return legacy;
}
+308
View File
@@ -0,0 +1,308 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ShareLinkManager } from '../sharelink.js';
import { createDefaultLayerState } from '../data/layerState.js';
import { HUD_LAYOUTS } from '../hudLayoutPolicy.js';
import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js';
import {
HAL_EXAMPLE_HASH,
pick,
randomLayerState,
rng,
} from '../testSupport/shareViewFixtures.mjs';
import {
SHARE_VIEW_PANELS,
SHARE_VIEW_STYLE_PARAMS,
decodeShareView,
encodeShareView,
} from './viewCodec.js';
import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js';
import {
LEGACY_SHARE_STATE_KEYS,
fromLegacyShareState,
toLegacyShareState,
} from './legacyShareState.js';
const DEG = Math.PI / 180;
function stubViewer(camera = {}) {
return {
camera: {
changed: { addEventListener: () => () => {} },
positionCartographic: camera.position ?? {
latitude: 0,
longitude: 0,
height: 0,
},
heading: camera.heading ?? 0,
pitch: camera.pitch ?? 0,
roll: camera.roll ?? 0,
},
};
}
/** The long form's own parser, run on `hash`. */
function parseLegacy(hash) {
const body = hash.startsWith('#') ? hash : `#${hash}`;
globalThis.window = {
location: { hash: body, href: `http://localhost/${body}` },
};
const manager = new ShareLinkManager(stubViewer());
const parsed = manager.parseInitialHash();
manager.destroy();
return parsed;
}
/**
* The parsed state as a view: restore bookkeeping removed, and headings and
* rolls wrapped into [0, 360) — the codec's one normalization of values the
* long-form generator writes (Cesium can round 359.6° up to `roll=360`).
*/
function comparable(parsed) {
const out = {};
for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key];
// A long link without `sp` leaves the style's parameters as they are; the
// compact form says what they are, the style's own unless changed.
const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams;
out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null;
for (const key of ['heading', 'roll'])
out[key] = ((out[key] % 360) + 360) % 360;
return out;
}
/** Legacy hash → parsed state → codec → parsed state, asserting equality. */
function assertLegacyRoundTrip(hash) {
const parsed = parseLegacy(hash);
assert.ok(parsed, `long form parses: ${hash}`);
const state = fromLegacyShareState(parsed);
const encoded = encodeShareView(state);
const decoded = decodeShareView(encoded, { nowMs: Date.now() });
assert.equal(decoded.ok, true, decoded.reason);
assert.deepEqual(decoded.state, state, 'codec state survives');
assert.deepEqual(
toLegacyShareState(decoded.state),
comparable(parsed),
'parsed long-form state survives',
);
return { parsed, state, encoded };
}
test('the HAL example survives the round trip and shrinks to 39 characters', () => {
const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH);
assert.equal(HAL_EXAMPLE_HASH.length, 302);
assert.equal(encoded.length, 39);
assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o');
assert.equal(parsed.roll, 360);
assert.equal(state.roll, 0);
assert.deepEqual(state.layerState.enabledLayerIds, [
'ais-live-vessels',
'flights',
'satellites',
]);
assert.equal(state.layerState.options.flights.models3d, true);
assert.equal(state.layerState.options.wind.overlay, 'none');
assert.deepEqual(
state.panelState.specs.find((spec) => spec.id === 'cctv-panel'),
{ id: 'cctv-panel', collapsed: false, pinned: null },
);
});
test('links from earlier long-form versions keep their historical meaning', () => {
for (const hash of [
'lat=10&lon=20',
'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal',
'v=2&lat=1&lon=2&l=f&lo=f.m.a',
'v=2&lat=1&lon=2&l=',
'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35',
'v=2&lat=1&lon=2&ui=v.c.0',
'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100',
'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600',
]) {
assertLegacyRoundTrip(hash);
}
// A pre-v2 link's omitted settings are written explicitly, so the compact
// form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35).
const old = fromLegacyShareState(parseLegacy('lat=10&lon=20'));
assert.deepEqual(
[old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct],
[5, 16, 35],
);
assert.deepEqual(
[old.bloomIntensity, old.bloomVersion],
[50, 1],
'legacy bloom scale',
);
// `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that.
const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f'));
assert.equal(off.layerState.options.flights.models3d, false);
});
test('a tracking selection becomes a thing view and back', () => {
const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3');
assert.deepEqual(flights.state.view.entity, {
kind: 'aircraft',
id: 'a1b2c3',
});
assert.equal(
flights.state.layerState.options.flights.selectedFlightsTrackingId,
null,
);
const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234');
assert.deepEqual(military.state.view.entity, {
kind: 'military_aircraft',
id: '~ae1234',
});
const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544');
assert.deepEqual(satellite.state.view.entity, {
kind: 'satellite',
id: '25544',
});
assert.equal(
toLegacyShareState(satellite.state).layerState.options.satellites
.selectedSatTrackingId,
25544,
);
});
test('thing views the long form cannot express convert without one', () => {
const layerState = createDefaultLayerState();
layerState.enabledLayerIds = ['ais-live-vessels'];
const vessel = toLegacyShareState({
lat: 1,
lon: 2,
layerState,
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
});
assert.deepEqual(vessel.layerState, layerState);
});
test('a thing view turns its layer on, with or without a layer set', () => {
for (const layerState of [createDefaultLayerState(), null]) {
const legacy = toLegacyShareState({
layerState,
view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } },
});
assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']);
assert.equal(
legacy.layerState.options.flights.selectedFlightsTrackingId,
'a1b2c3',
);
assert.equal(legacy.layerStateInvalid, false);
}
const vessel = toLegacyShareState({
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
});
assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']);
// A place view without a layer set leaves the recipient's layers alone.
assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null);
});
/** A long-form link written by the real generator from random live state. */
function generatedLegacyHash(random) {
const int = (min, max) => min + Math.floor(random() * (max - min + 1));
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const manager = new ShareLinkManager(
stubViewer({
position: {
latitude: (int(-899999, 899999) / 1e4) * DEG,
longitude: (int(-1799999, 1799999) / 1e4) * DEG,
height: pick(random, [int(-50, 3000), int(0, 30_000_000)]),
},
heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0),
pitch: int(-90, 0) * DEG,
roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]),
}),
);
const style = pick(random, VIEW_STYLES);
manager.onStyleChange(style);
const density = pick(random, [0, 25, 50, 75, 100]);
manager.onToggleChange(random() < 0.5, random() < 0.5, {
bloomIntensity: int(0, 100),
bloomVersion: pick(random, [1, 2]),
sharpenIntensity: int(0, 100),
hudVariant: pick(random, HUD_LAYOUTS),
hudVisible: random() < 0.5,
detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']),
detectionDensity: density,
detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']),
detectionFadePct: int(0, 40),
detectionOutsideOpacityPct: int(0, 100),
celestialRingEnabled: random() < 0.5,
scopeEnabled: random() < 0.5,
scopeFeatherPct: int(0, 200),
scopeTerminusPct: random() < 0.3 ? int(94, 100) : null,
mapStack: pick(random, VIEW_MAPS),
});
if (random() < 0.8) {
const layerState = randomLayerState(random);
manager.setLayerStateProvider(() => layerState);
}
if (random() < 0.8) {
const panelState = {
specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({
id: panel.id,
collapsed: random() < 0.7,
...(panel.pinnable ? { pinned: random() < 0.3 } : {}),
})),
};
manager.setPanelStateProvider(() => panelState);
}
const specs = SHARE_VIEW_STYLE_PARAMS[style];
if (specs && random() < 0.7) {
const values = Object.fromEntries(
specs.map((spec) => [
spec.key,
spec.min + (spec.max - spec.min) * random(),
]),
);
manager.setStyleParamStateProvider(() => values);
}
const params = manager._buildHashParams();
if (random() < 0.7)
params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000))));
manager.destroy();
return params.toString();
}
test('links written by the long-form generator survive the compact form', () => {
const random = rng(0xc0dec);
let totalLegacy = 0;
let totalCompact = 0;
for (let i = 0; i < 400; i += 1) {
const hash = generatedLegacyHash(random);
const { encoded } = assertLegacyRoundTrip(hash);
totalLegacy += hash.length;
totalCompact += encoded.length;
}
// Even random states, far from defaults, are under half the length.
assert.ok(
totalCompact * 2 < totalLegacy,
`${totalCompact} vs ${totalLegacy}`,
);
});
test('the converter ignores non-objects and restore bookkeeping', () => {
// A malformed v2 layer list is reported by the long-form parser; the view
// itself simply has no layer set.
const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown');
assert.equal(invalid.layerStateInvalid, true);
assert.equal(fromLegacyShareState(invalid).layerState, null);
assert.equal(fromLegacyShareState(null), null);
const parsed = parseLegacy(HAL_EXAMPLE_HASH);
assert.ok(parsed.restoreAuthority);
const state = fromLegacyShareState(parsed);
assert.equal(Object.hasOwn(state, 'restoreAuthority'), false);
assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false);
});
test('the converter knows every key of the long form’s parsed state', () => {
// A key the converter does not list would silently drop out of share codes.
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const manager = new ShareLinkManager(stubViewer({}));
const parsed = manager._parseShareHash('v=2&lat=1&lon=2');
assert.deepEqual(
Object.keys(parsed).sort(),
[...LEGACY_SHARE_STATE_KEYS].sort(),
);
manager.destroy();
});
+56
View File
@@ -0,0 +1,56 @@
/**
* Share-link addresses: a share code travels in the path, `<app>/share/<code>`,
* where `<app>` is wherever the app is served (the origin's root, or a base
* path). The same code opens on any copy of the app.
*
* Pure: no DOM. Callers pass the addresses they have.
*/
/** The path segment that introduces a share code. */
export const SHARE_PATH_SEGMENT = 'share';
// Any last segment counts as a code here, so a mangled one is refused by the
// decoder (and said so) rather than opening the app at a wrong address.
const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`);
/**
* The share code and the app's base path for a share-link pathname, or null
* when the pathname is not a share link. `/share/AQc` gives
* `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`.
*/
export function parseSharePath(pathname) {
if (typeof pathname !== 'string') return null;
const match = SHARE_PATH.exec(pathname);
if (!match) return null;
let code = match[2];
try {
code = decodeURIComponent(code);
} catch {
// Left as written; the decoder refuses it.
}
return { code, basePath: match[1] };
}
/**
* The app's root address for `href`: the base path of a share link, or the
* directory of any other page, with no hash. The query is kept.
*/
export function appRootUrl(href) {
const url = new URL(href);
url.hash = '';
const share = parseSharePath(url.pathname);
url.pathname = share ? share.basePath : new URL('./', url).pathname;
return url;
}
/**
* The share link for `code` on the app at `href`. The sender's `?lang` stays
* behind: the recipient keeps their own language.
*/
export function shareLinkUrl(code, href) {
const root = appRootUrl(href);
const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root);
url.search = root.search;
url.searchParams.delete('lang');
return url.href;
}
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js';
test('a share path names its code and the app base path', () => {
assert.deepEqual(parseSharePath('/share/AQc'), {
code: 'AQc',
basePath: '/',
});
assert.deepEqual(parseSharePath('/gev/share/AQc/'), {
code: 'AQc',
basePath: '/gev/',
});
// A mangled code is still a share link, so the app refuses it out loud.
assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c');
for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc'])
assert.equal(parseSharePath(path), null, path);
assert.equal(parseSharePath(undefined), null);
});
test('the app root drops a share path and the hash, and keeps the query', () => {
assert.equal(
appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href,
'https://gev.example/?qa=1',
);
assert.equal(
appRootUrl('http://localhost:4173/gev/share/AQc').href,
'http://localhost:4173/gev/',
);
assert.equal(
appRootUrl('https://gev.example/index.html#v=2&lat=1').href,
'https://gev.example/',
);
});
test('a share link sits under the app root without the sender ?lang', () => {
assert.equal(
shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'),
'https://gev.example/share/AQc',
);
assert.equal(
shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'),
'https://gev.example/gev/share/AQc?qa=1',
);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+259
View File
@@ -0,0 +1,259 @@
/**
* Byte-level primitives for the compact share-view format: base64url text,
* protobuf-style varints, and a tag-length-value reader and writer.
*
* Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a
* {@link ShareWireError} with a stable `reason`, so the view codec can turn it
* into a rejected link instead of an exception at app start.
*/
/** Wire types. Field numbers and their wire types are permanent. */
export const WIRE_VARINT = 0;
export const WIRE_FIXED64 = 1;
export const WIRE_LEN = 2;
export const WIRE_FIXED32 = 5;
/** Field numbers stay inside protobuf's range so tags fit in five bytes. */
export const MAX_FIELD_NUMBER = 2 ** 29 - 1;
const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer.
/** A malformed or out-of-bounds payload. `reason` is a stable code. */
export class ShareWireError extends Error {
constructor(reason, detail = '') {
super(detail ? `${reason}: ${detail}` : reason);
this.name = 'ShareWireError';
this.reason = reason;
}
}
const BASE64URL =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const BASE64URL_INDEX = new Map(
[...BASE64URL].map((char, index) => [char, index]),
);
/** Unpadded base64url text for `bytes`. */
export function encodeBase64Url(bytes) {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
const triple = (a << 16) | (b << 8) | c;
out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63];
if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63];
if (i + 2 < bytes.length) out += BASE64URL[triple & 63];
}
return out;
}
/**
* Bytes for unpadded base64url text. Rejects padding, foreign characters, an
* impossible length and non-zero trailing bits, so every payload has exactly
* one spelling.
*/
export function decodeBase64Url(text) {
if (typeof text !== 'string' || text.length === 0)
throw new ShareWireError('empty');
if (text.length % 4 === 1) throw new ShareWireError('bad-base64');
const bytes = new Uint8Array(Math.floor((text.length * 3) / 4));
let buffer = 0;
let bits = 0;
let out = 0;
for (const char of text) {
const value = BASE64URL_INDEX.get(char);
if (value === undefined) throw new ShareWireError('bad-base64');
buffer = ((buffer << 6) | value) & 0xffffff;
bits += 6;
if (bits >= 8) {
bits -= 8;
bytes[out++] = (buffer >> bits) & 0xff;
}
}
if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0)
throw new ShareWireError('bad-base64', 'non-zero trailing bits');
return bytes;
}
/**
* CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection;
* no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to
* 8 bits, so any one mistyped base64url character (6 bits) is caught.
*/
export function crc8(bytes) {
let crc = 0;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1)
crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
}
return crc;
}
/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */
export function zigzag(value) {
return value >= 0 ? value * 2 : -value * 2 - 1;
}
/** Inverse of {@link zigzag}. */
export function unzigzag(value) {
return value % 2 === 0 ? value / 2 : -(value + 1) / 2;
}
/** Largest magnitude a zigzag field can carry without losing precision. */
export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2);
/** Append-only byte sink with protobuf-style field helpers. */
export class WireWriter {
constructor() {
this.bytes = [];
}
varint(value) {
if (!Number.isSafeInteger(value) || value < 0)
throw new RangeError(`varint out of range: ${value}`);
let rest = value;
while (rest >= 0x80) {
this.bytes.push((rest % 0x80) | 0x80);
rest = Math.floor(rest / 0x80);
}
this.bytes.push(rest);
return this;
}
tag(field, wireType) {
if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER)
throw new RangeError(`field number out of range: ${field}`);
return this.varint(field * 8 + wireType);
}
uintField(field, value) {
return this.tag(field, WIRE_VARINT).varint(value);
}
sintField(field, value) {
if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE)
throw new RangeError(`signed value out of range: ${value}`);
return this.tag(field, WIRE_VARINT).varint(zigzag(value));
}
bytesField(field, bytes) {
this.tag(field, WIRE_LEN).varint(bytes.length);
for (const byte of bytes) this.bytes.push(byte);
return this;
}
/** ASCII-only text; every share-view string is drawn from ASCII grammars. */
textField(field, text) {
return this.bytesField(field, asciiBytes(text));
}
finish() {
return Uint8Array.from(this.bytes);
}
}
/** Printable-ASCII bytes for `text`; anything else is a programming error. */
export function asciiBytes(text) {
const bytes = [];
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
if (code < 0x20 || code > 0x7e)
throw new RangeError('share-view text must be printable ASCII');
bytes.push(code);
}
return bytes;
}
/** Printable-ASCII text from bytes; anything else is a malformed payload. */
export function asciiText(bytes) {
let out = '';
for (const byte of bytes) {
if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text');
out += String.fromCharCode(byte);
}
return out;
}
/** Bounds-checked cursor over one message (or one packed blob). */
export class WireReader {
constructor(bytes, start = 0, end = bytes.length) {
this.bytes = bytes;
this.pos = start;
this.end = end;
}
get done() {
return this.pos >= this.end;
}
/** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */
varint() {
let value = 0;
let scale = 1;
for (let count = 0; count < MAX_VARINT_BYTES; count += 1) {
if (this.pos >= this.end) throw new ShareWireError('truncated');
const byte = this.bytes[this.pos++];
value += (byte & 0x7f) * scale;
if ((byte & 0x80) === 0) {
// A zero final byte after a continuation is an overlong spelling.
if (count > 0 && byte === 0)
throw new ShareWireError('non-canonical-varint');
if (!Number.isSafeInteger(value))
throw new ShareWireError('varint-overflow');
return value;
}
scale *= 0x80;
}
throw new ShareWireError('varint-overflow');
}
svarint() {
return unzigzag(this.varint());
}
/** The next field's number and wire type, rejecting reserved wire types. */
tag() {
const key = this.varint();
const field = Math.floor(key / 8);
const wireType = key % 8;
if (field < 1 || field > MAX_FIELD_NUMBER)
throw new ShareWireError('bad-field-number');
if (
wireType !== WIRE_VARINT &&
wireType !== WIRE_FIXED64 &&
wireType !== WIRE_LEN &&
wireType !== WIRE_FIXED32
)
throw new ShareWireError('bad-wire-type', String(wireType));
return { field, wireType };
}
/** A length-delimited body as a sub-reader over the same bytes. */
lengthDelimited() {
const length = this.varint();
if (length > this.end - this.pos)
throw new ShareWireError('truncated', 'length exceeds payload');
const reader = new WireReader(this.bytes, this.pos, this.pos + length);
this.pos += length;
return reader;
}
/** Remaining bytes of this reader as a fresh array. */
rest() {
const out = this.bytes.slice(this.pos, this.end);
this.pos = this.end;
return out;
}
/** Skip one value of `wireType`, so newer fields cost older decoders nothing. */
skip(wireType) {
if (wireType === WIRE_VARINT) this.varint();
else if (wireType === WIRE_LEN) this.lengthDelimited();
else {
const size = wireType === WIRE_FIXED64 ? 8 : 4;
if (size > this.end - this.pos) throw new ShareWireError('truncated');
this.pos += size;
}
}
}
+188
View File
@@ -0,0 +1,188 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ShareWireError,
WIRE_FIXED32,
WIRE_FIXED64,
WIRE_LEN,
WIRE_VARINT,
WireReader,
WireWriter,
crc8,
decodeBase64Url,
encodeBase64Url,
unzigzag,
zigzag,
} from './wire.js';
const reasonOf = (fn) => {
try {
fn();
} catch (error) {
assert.ok(error instanceof ShareWireError, String(error));
return error.reason;
}
assert.fail('expected a ShareWireError');
};
test('base64url round-trips every length and byte value without padding', () => {
for (let length = 0; length < 70; length += 1) {
const bytes = Uint8Array.from(
{ length },
(_, i) => (i * 97 + length) & 0xff,
);
const text = encodeBase64Url(bytes);
assert.match(text, /^[A-Za-z0-9_-]*$/);
if (length) assert.deepEqual(decodeBase64Url(text), bytes);
}
const all = Uint8Array.from({ length: 256 }, (_, i) => i);
assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all);
assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8');
});
test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => {
for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) {
assert.ok(
['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))),
text,
);
}
// "AR" carries one byte (0x01) plus four trailing bits that must be zero.
assert.equal(
reasonOf(() => decodeBase64Url('AR')),
'bad-base64',
);
assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1));
});
test('zigzag maps small magnitudes of either sign to small codes', () => {
assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]);
for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) {
assert.equal(unzigzag(zigzag(value)), value);
}
});
test('varints round-trip up to the largest safe integer', () => {
const values = [
0,
1,
127,
128,
300,
2 ** 21,
2 ** 31,
2 ** 32 + 5,
Number.MAX_SAFE_INTEGER,
];
const writer = new WireWriter();
for (const value of values) writer.varint(value);
const reader = new WireReader(writer.finish());
for (const value of values) assert.equal(reader.varint(), value);
assert.ok(reader.done);
assert.deepEqual(
new WireWriter().varint(300).finish(),
Uint8Array.of(0xac, 0x02),
);
});
test('the writer refuses values the format cannot carry', () => {
assert.throws(() => new WireWriter().varint(-1), RangeError);
assert.throws(() => new WireWriter().varint(1.5), RangeError);
assert.throws(() => new WireWriter().varint(2 ** 53), RangeError);
assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError);
assert.throws(() => new WireWriter().textField(1, 'é'), RangeError);
});
test('the reader rejects truncated, overlong and overflowing varints', () => {
assert.equal(
reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()),
'truncated',
);
assert.equal(
reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()),
'non-canonical-varint',
);
const tooLong = new Uint8Array(9).fill(0xff);
assert.equal(
reasonOf(() => new WireReader(tooLong).varint()),
'varint-overflow',
);
const beyondSafe = Uint8Array.of(
0xff,
0xff,
0xff,
0xff,
0xff,
0xff,
0xff,
0x7f,
);
assert.equal(
reasonOf(() => new WireReader(beyondSafe).varint()),
'varint-overflow',
);
});
test('the reader bounds lengths and rejects group and reserved wire types', () => {
const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish();
const reader = new WireReader(absurd);
reader.tag();
assert.equal(
reasonOf(() => reader.lengthDelimited()),
'truncated',
);
for (const wireType of [3, 4, 6, 7]) {
const bytes = new WireWriter().varint(8 + wireType).finish();
assert.equal(
reasonOf(() => new WireReader(bytes).tag()),
'bad-wire-type',
);
}
assert.equal(
reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()),
'bad-field-number',
);
});
test('skip steps over each wire type and stops at a truncated value', () => {
const writer = new WireWriter()
.uintField(1, 300)
.bytesField(2, [1, 2, 3])
.tag(3, WIRE_FIXED32);
writer.bytes.push(1, 2, 3, 4);
writer.tag(4, WIRE_FIXED64);
writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8);
writer.uintField(5, 9);
const reader = new WireReader(writer.finish());
for (let field = 1; field <= 4; field += 1)
reader.skip(reader.tag().wireType);
assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT });
assert.equal(reader.varint(), 9);
const short = new WireWriter().tag(1, WIRE_FIXED64).finish();
const shortReader = new WireReader(Uint8Array.of(...short, 1, 2));
assert.equal(
reasonOf(() => shortReader.skip(shortReader.tag().wireType)),
'truncated',
);
});
test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => {
// The catalogued check value for the ASCII digits 1–9.
assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4);
assert.equal(crc8([]), 0);
const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33];
const sum = crc8(bytes);
for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) {
for (let pattern = 1; pattern < 256; pattern += 1) {
// Flip a burst starting at `bit` (its first bit always set).
if (!(pattern & 1)) continue;
const flipped = [...bytes];
for (let k = 0; k < 8; k += 1) {
if (!(pattern & (1 << k))) continue;
const at = bit + k;
flipped[at >> 3] ^= 0x80 >> (at & 7);
}
assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`);
}
}
});