feat(06-04): /api/route engine-A arm + http-source backend #2 — Bearer-gated, honest-null, RP re-pinned (GREEN)

- bot/src/routing/worker-router.ts: module-scope per-env graph memo (1 R2 GET per
  cold isolate, shared with the 06-07 cron arm via loadGraph), snap+A*+deriveSteps,
  claim-gated 401 / 4096-cap 413 / V5 bbox 400, 200-null on every miss class,
  ONE best route (no alternatives), category-only logs
- webhook.ts: /api/route arm delegating to handleRoute (405 non-POST)
- config.ts: ROAD_GRAPH: R2Bucket — first R2 binding (never a KV namespace, GE5)
- wrangler.jsonc: r2_buckets ROAD_GRAPH -> araharah-graphs with the fork law comment
- routing/src/http-source.ts: createHttpRoutingSource — slug-guard-first, fail-honest
  (throw/non-200/malformed/null-body -> null), defensive RouteResult validation;
  exported from index.ts; ladder note: backend #2 LIVE
- A7 re-pinned (06-01 named 06-04 as the owner of createHttpRoutingSource)
- test-side fix during GREEN: the R2 fake hands decodeGraph an exact ArrayBuffer
  (Node Buffers are views; encodeGraph output is already an ArrayBuffer)
- bot 103 passed / routing 65 passed; both typechecks clean
This commit is contained in:
araharah-executor
2026-10-24 13:27:02 +08:00
parent 0abe64e734
commit 020eb92058
8 changed files with 376 additions and 16 deletions
+7
View File
@@ -9,6 +9,13 @@ export interface Env {
// accept-everything. // accept-everything.
WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token
chat_sessions: KVNamespace; // TTL-only namespace — first durable KV namespace (PRIV-02) chat_sessions: KVNamespace; // TTL-only namespace — first durable KV namespace (PRIV-02)
// D-01 graph binary — the repo's FIRST R2 binding. A bucket, NEVER a KV
// namespace (GE5: exactly one kv_namespaces). worker-router's loadGraph
// memoizes the decoded graph per isolate; an absent/corrupt object
// degrades honest (null → router-pending, D-03), never a crash. The
// bucket itself (araharah-graphs) is fork provisioning, not a secret —
// docs/ROAD-GRAPH.md owns the fork contract.
ROAD_GRAPH: R2Bucket;
// The deployed Mini App ORIGIN (public, not a secret) — used to build the // The deployed Mini App ORIGIN (public, not a secret) — used to build the
// OPEN button's deep link. NOT derived from user input: the deep-link // OPEN button's deep link. NOT derived from user input: the deep-link
// origin is a configured value (RESEARCH §Security V4 — an unvalidated // origin is a configured value (RESEARCH §Security V4 — an unvalidated
+200
View File
@@ -0,0 +1,200 @@
/*
* worker-router.ts — engine A's server surface: the /api/route handler (D-01).
* ------------------------------------------------------------------------------
* snap origin → snap destination → A* → RouteResult over the R2-hosted KL
* road graph. THIS is the guidance engine (D-03): an arbitrary current
* position routes for real, $0, no VPS. The client reaches it through
* @araharah/routing's createHttpRoutingSource (backend #2).
*
* THE GRAPH LOAD — isolate memoization is the cache that works (RESEARCH):
* `caches.default` is useless here (Bearer requests auto-bypass the Workers
* Cache; cron is never cached), so the decoded graph memoizes in a
* module-scope map keyed on `env` — the plan's `graphP ??=` law in its
* per-env-safe form. Production effect is the same: ONE R2 GET per cold
* isolate, the memo is never reset, and the cron arm (plan 06-07) imports
* THIS SAME loadGraph so either arm warming it serves the other. The map is
* keyed on env (not a bare module variable) so distinct bindings — tests,
* multi-binding tooling — can never read each other's graph.
*
* HONEST DEGRADATION (T-06-03): a missing object, a failed read, or a
* binary decodeGraph rejects resolves null → the request answers JSON null
* with 200 — the designed router-pending state (D-03), never a 500. The
* null contract crosses HTTP unchanged.
*
* SECURITY POSTURE (T-06-01 / T-06-01b / T-06-04):
* - identity is the Bearer CLAIM (getClaimByToken — the webhook.ts
* posture verbatim); no/invalid token is a category-only 401
* - the body is capped at 4096 chars (413; the initData/guidance
* precedent) BEFORE any parse work
* - the origin is type-checked and range-checked against graph.bbox
* BEFORE any snap/A* work (V5) — untrusted coordinates never reach the
* compute-heavy path
* - an unknown destinationId short-circuits before any graph work
* - ONE best route — alternatives generation is PROHIBITED on this path
* (the RESEARCH anti-pattern: 2-3 penalty runs inside an active-drive
* storm-guard window is CPU risk with zero UX need)
* - the ONLY log lines are "[route] rejected: <category>" — never
* coordinates, ids, or payloads (the webhook law, D-14)
*
* The routing computation is LOCAL: this module never fetches, never
* proxies — the Worker IS the routing oracle (RP1).
*/
import {
decodeGraph,
deriveCumulativeDistanceM,
deriveSteps,
routeAstar,
snapToGraph,
} from "@araharah/routing/src/index.ts";
import type { RoadGraph, RoutePoint, RouteResult } from "@araharah/routing/src/index.ts";
import { getEntryById } from "@araharah/search/src/gazetteer.ts";
import type { Env } from "../config.ts";
import { getClaimByToken } from "../kv/sessions.ts";
/** The R2 object key this Worker loads (upload-road-graph.mjs puts it). */
export const GRAPH_KEY = "kl-roadgraph.v1.bin";
/** The /api/route body cap — an honest request is ≪ 1 KB; the WR-01 law. */
const MAX_ROUTE_BODY_CHARS = 4096;
/** Destination ids are gazetteer slugs — the search workspace's own law. */
const DESTINATION_ID_PATTERN = /^[a-z0-9-]{1,64}$/;
/* ————— the per-isolate graph memo (see header) ————— */
const graphMemo = new WeakMap<object, Promise<RoadGraph | null>>();
/** The decoded road graph, or null when absent/corrupt (honest, D-03).
* Module-scope memo: 1 R2 GET per cold isolate, shared with the cron arm. */
export function loadGraph(env: Env): Promise<RoadGraph | null> {
let memoized = graphMemo.get(env);
if (memoized === undefined) {
memoized = env.ROAD_GRAPH.get(GRAPH_KEY)
.then(async (obj) => (obj === null ? null : decodeGraph(await obj.arrayBuffer())))
.catch(() => null); // a failed read degrades exactly like a corrupt binary (T-06-03)
graphMemo.set(env, memoized);
}
return memoized;
}
/* ————— response shapes ————— */
function jsonHeaders(): HeadersInit {
return { "content-type": "application/json" };
}
function routeUnauthorized(): Response {
console.log("[route] rejected: unauthorized"); // category ONLY (T-06-04)
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: jsonHeaders() });
}
function routeMalformed(): Response {
console.log("[route] rejected: malformed"); // category ONLY — never the payload, never coordinates
return new Response(JSON.stringify({ error: "malformed" }), { status: 400, headers: jsonHeaders() });
}
function routeOversize(): Response {
console.log("[route] rejected: malformed"); // the cap is a malformed-class rejection (WR-01)
return new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: jsonHeaders() });
}
/** The null contract crossing HTTP: 200 with a JSON null body (D-03). */
function routePending(): Response {
return new Response(JSON.stringify(null), { status: 200, headers: jsonHeaders() });
}
/* ————— body validation (shape only — the bbox law needs the graph) ————— */
interface RouteBody {
origin: RoutePoint;
destinationId: string;
}
function validateRouteBody(parsed: unknown): RouteBody | null {
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const b = parsed as Record<string, unknown>;
const origin = b.origin;
if (origin === null || typeof origin !== "object" || Array.isArray(origin)) return null;
const o = origin as Record<string, unknown>;
if (typeof o.lat !== "number" || !Number.isFinite(o.lat)) return null;
if (typeof o.lng !== "number" || !Number.isFinite(o.lng)) return null;
if (typeof b.destinationId !== "string" || !DESTINATION_ID_PATTERN.test(b.destinationId)) return null;
return { origin: { lat: o.lat, lng: o.lng }, destinationId: b.destinationId };
}
/**
* POST /api/route — Bearer-gated engine-A routing. See the module header
* for the threat posture. Every miss class (unknown destination, snap miss,
* unreachable pair, absent/corrupt graph) answers 200 null: honest
* router-pending, never an error (D-03).
*/
export async function handleRoute(req: Request, env: Env): Promise<Response> {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
// V4 access control, the webhook.ts posture verbatim: the Bearer token
// resolves through getClaimByToken. The claim is the IDENTITY GATE —
// routing itself is chat-agnostic and nothing about the caller is stored,
// logged, or echoed.
const header = req.headers.get("authorization") ?? "";
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
const token = match !== null && match[1] ? match[1] : null;
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
if (claim === null) return routeUnauthorized();
// WR-01: reject over-cap bodies before any parse work; the post-read
// check backstops a lying or absent Content-Length.
if (Number(req.headers.get("content-length") ?? "0") > MAX_ROUTE_BODY_CHARS) return routeOversize();
const raw = await req.text();
if (raw.length > MAX_ROUTE_BODY_CHARS) return routeOversize();
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return routeMalformed();
}
const body = validateRouteBody(parsed);
if (body === null) return routeMalformed();
const graph = await loadGraph(env);
if (graph === null) return routePending(); // absent/corrupt graph → router-pending (D-03)
// V5 (T-06-01b): the origin is range-validated against graph.bbox BEFORE
// any snap/A* work — untrusted coordinates never reach the compute path.
const { minLat, minLng, maxLat, maxLng } = graph.bbox;
if (
body.origin.lat < minLat ||
body.origin.lat > maxLat ||
body.origin.lng < minLng ||
body.origin.lng > maxLng
) {
return routeMalformed();
}
// Unknown destination short-circuits before ANY graph work (T-06-01b).
const dest = getEntryById(body.destinationId);
if (dest === null) return routePending();
const originSnap = snapToGraph(graph, body.origin);
if (originSnap === null) return routePending(); // in a nodeless corner — honest pending
const destSnap = snapToGraph(graph, { lat: dest.lat, lng: dest.lng });
if (destSnap === null) return routePending();
const astar = routeAstar(graph, originSnap.node, destSnap.node);
if (astar === null) return routePending(); // disconnected pair — honest pending
const points: RoutePoint[] = astar.nodes.map((n) => ({ lat: graph.lat[n]!, lng: graph.lng[n]! }));
const result: RouteResult = {
destinationId: body.destinationId,
origin: body.origin,
options: [
{
geometry: { points, cumulativeDistanceM: deriveCumulativeDistanceM(points) },
summary: { distanceM: astar.distanceM, durationS: astar.seconds },
steps: deriveSteps(graph, astar.nodes),
},
],
backend: "engine-a",
};
return new Response(JSON.stringify(result), { status: 200, headers: jsonHeaders() });
}
+9
View File
@@ -39,6 +39,11 @@
* structurally ignored (T-06-02); GET/DELETE resolve the record and * structurally ignored (T-06-02); GET/DELETE resolve the record and
* compare envelope.chat_id === claim.chat_id, so foreign and absent * compare envelope.chat_id === claim.chat_id, so foreign and absent
* are indistinguishable 404s (T-06-02b). DELETE is D-14's server half. * are indistinguishable 404s (T-06-02b). DELETE is D-14's server half.
* POST /api/route → engine A's arm (06-04, D-01): Bearer-claim
* gated EXACTLY like /api/location (T-06-01); the handler lives in
* ./routing/worker-router.ts — the R2-hosted road graph, snap, A*, and
* the honest-null degradation (D-03) all live there. Every miss class
* answers 200 null; rejections are category-only (T-06-04).
* Anything else → 404. * Anything else → 404.
* *
* D-14: the ONLY log statements are category-only — malformed/tampered/stale * D-14: the ONLY log statements are category-only — malformed/tampered/stale
@@ -60,6 +65,7 @@ import {
deleteGuidanceSession, deleteGuidanceSession,
type GuidanceRouteSnapshot, type GuidanceRouteSnapshot,
} from "./kv/guidance-sessions.ts"; } from "./kv/guidance-sessions.ts";
import { handleRoute } from "./routing/worker-router.ts";
import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"; import { decodePolyline6 } from "@araharah/routing/src/polyline.ts";
import { validateInitData } from "./security/initdata.ts"; import { validateInitData } from "./security/initdata.ts";
import { pickLocale } from "./i18n/strings.ts"; import { pickLocale } from "./i18n/strings.ts";
@@ -95,6 +101,9 @@ export default {
if (url.pathname === "/api/guidance") { if (url.pathname === "/api/guidance") {
return handleGuidance(req, env); return handleGuidance(req, env);
} }
if (url.pathname === "/api/route") {
return handleRoute(req, env);
}
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
}, },
} satisfies ExportedHandler<Env>; } satisfies ExportedHandler<Env>;
+10 -2
View File
@@ -522,10 +522,18 @@ const CORRIDOR_BUF = corridorGraphBuffer();
const SPLIT_BUF = corridorGraphBuffer({ gap: true }); const SPLIT_BUF = corridorGraphBuffer({ gap: true });
const SPARSE_BUF = sparseGraphBuffer(); const SPARSE_BUF = sparseGraphBuffer();
/** An R2 fake over one fixed binary; counts + records its gets (the memo probe). */ /** An R2 fake over one fixed binary; counts + records its gets (the memo probe).
* Node Buffers are views over a pooled ArrayBuffer — the fake hands over an
* exact copy (the astar suite's toArrayBuffer law) so decodeGraph sees what
* a real R2 arrayBuffer() returns. */
function r2With(maybeBuf) { function r2With(maybeBuf) {
let gets = 0; let gets = 0;
let lastKey = null; let lastKey = null;
const exact = () => {
if (maybeBuf === null) return null;
if (maybeBuf instanceof ArrayBuffer) return maybeBuf; // encodeGraph's product
return maybeBuf.buffer.slice(maybeBuf.byteOffset, maybeBuf.byteOffset + maybeBuf.byteLength); // fs Buffer (view)
};
return { return {
gets: () => gets, gets: () => gets,
lastKey: () => lastKey, lastKey: () => lastKey,
@@ -533,7 +541,7 @@ function r2With(maybeBuf) {
gets += 1; gets += 1;
lastKey = key; lastKey = key;
if (maybeBuf === null) return null; // R2 object absent if (maybeBuf === null) return null; // R2 object absent
return { arrayBuffer: async () => maybeBuf }; return { arrayBuffer: async () => exact() };
}, },
}; };
} }
+8
View File
@@ -20,5 +20,13 @@
// It must equal the Mini App URL registered in @BotFather (Bot API 10.2 // It must equal the Mini App URL registered in @BotFather (Bot API 10.2
// enforces the origin). Never derived from user input. // enforces the origin). Never derived from user input.
"vars": { "MINI_APP_URL": "https://araharah-bot.araharah-bot.workers.dev" }, "vars": { "MINI_APP_URL": "https://araharah-bot.araharah-bot.workers.dev" },
// R2 binding (D-01, the repo's FIRST r2_buckets entry): hosts the road-graph
// binary /api/route loads (worker-router.ts memoizes ONE GET per cold
// isolate). A bucket is NOT a KV namespace — GE5 (exactly ONE
// kv_namespaces) still holds. Forks create + upload their OWN bucket:
// `npx wrangler r2 bucket create <name>` + routing/scripts/upload-road-graph.mjs
// (docs/ROAD-GRAPH.md is the fork contract). The bucket name is public
// configuration, never a secret.
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
} }
+128
View File
@@ -0,0 +1,128 @@
/*
* http-source.ts — RoutingSource backend #2's client seam (D-01, D-03).
* ---------------------------------------------------------------------------
* createHttpRoutingSource plugs the Worker's Bearer-gated POST /api/route
* (engine A: snap + A* over the R2-hosted CSR graph) beneath the Phase 5
* RoutingSource seam, so the web app's route flow needs no rewrite: the
* backend ladder becomes fixture (demo corpus) → engine-a (real routing,
* THIS module) → engine B (fully client-side, documented post-v1, D-02).
*
* THE LADDER LAW HOLDS: backend selection happens at the import site, in
* code — no env read, no config read, no dynamic registry (T-05-04). The
* endpoint and the bearer getter are INJECTED by the caller (web wiring,
* plan 06-05): the module itself never reads storage and never holds a
* token — `bearer` is a getter so a token minted after construction is
* still sent (the memory-held session token's lifetime law).
*
* FAIL-HONEST (T-05-04's .catch(() => null) law, network edition): a
* thrown fetch, a non-200, a malformed body, or a shape violation resolves
* NULL — the honest "router pending" state (D-03), never a throw in the
* webview's render path and never a fabricated route. A 200 with a JSON
* null body IS the null contract crossing HTTP; it resolves null too.
*
* DEFENSIVE VALIDATION (the fixture-source isUsablePair posture): every
* field of the response is type-checked — the destinationId must answer
* THIS request, options must number 1..3, every number finite, the
* cumulativeDistanceM array must match points 1:1 (the day-one progress
* contract). A payload that fails any check is treated as no answer.
*
* The slug guard runs FIRST: a hostile destinationId never reaches the
* network (the fixture-source posture).
*
* Pure client: no logging, no storage. The origin travels to OUR Worker in
* the request body the caller asked for (the one in-drive position the
* privacy law permits transmitting — RESEARCH Pattern 4); nothing here
* persists or logs it.
*/
import type { RoutePoint, RouteRequest, RouteResult, RoutingSource } from "./types.ts";
/** Destination ids are gazetteer slugs — the search workspace's own law. */
const ID_PATTERN = /^[a-z0-9-]{1,64}$/;
export interface HttpRoutingSourceOptions {
/** The Worker's /api/route URL (the configured Mini App origin). */
endpoint: string;
/** Returns the memory-held session token, or null when logged out. */
bearer: () => string | null;
/** Injectable fetch — tests pass a fake; production uses the global. */
fetchImpl?: typeof fetch;
}
function isRoutePoint(value: unknown): value is RoutePoint {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const p = value as Record<string, unknown>;
return typeof p.lat === "number" && Number.isFinite(p.lat) && typeof p.lng === "number" && Number.isFinite(p.lng);
}
/**
* Every field type-checked, every number finite, options 1..3, and the
* geometry arrays consistent (points 1:1 with cumulativeDistanceM). Returns
* the payload narrowed to RouteResult, or null — the caller's contract.
*/
function validatedResult(payload: unknown, request: RouteRequest): RouteResult | null {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return null;
const r = payload as Record<string, unknown>;
if (r.destinationId !== request.destinationId) return null; // must answer THIS request
if (!isRoutePoint(r.origin)) return null;
if (typeof r.backend !== "string" || r.backend.length === 0) return null;
if (!Array.isArray(r.options) || r.options.length < 1 || r.options.length > 3) return null;
for (const option of r.options) {
if (option === null || typeof option !== "object" || Array.isArray(option)) return null;
const o = option as Record<string, unknown>;
const geometry = o.geometry;
if (geometry === null || typeof geometry !== "object" || Array.isArray(geometry)) return null;
const g = geometry as Record<string, unknown>;
if (!Array.isArray(g.points) || g.points.length < 1) return null;
for (const point of g.points) {
if (!isRoutePoint(point)) return null;
}
if (!Array.isArray(g.cumulativeDistanceM) || g.cumulativeDistanceM.length !== g.points.length) return null;
for (const meters of g.cumulativeDistanceM) {
if (typeof meters !== "number" || !Number.isFinite(meters) || meters < 0) return null;
}
const summary = o.summary;
if (summary === null || typeof summary !== "object" || Array.isArray(summary)) return null;
const s = summary as Record<string, unknown>;
if (typeof s.distanceM !== "number" || !Number.isFinite(s.distanceM) || s.distanceM < 0) return null;
if (typeof s.durationS !== "number" || !Number.isFinite(s.durationS) || s.durationS < 0) return null;
if (!Array.isArray(o.steps)) return null;
for (const step of o.steps) {
if (step === null || typeof step !== "object" || Array.isArray(step)) return null;
const st = step as Record<string, unknown>;
if (st.distanceM !== undefined && (typeof st.distanceM !== "number" || !Number.isFinite(st.distanceM))) return null;
if (st.geometry !== undefined && typeof st.geometry !== "string") return null;
if (st.name !== undefined && typeof st.name !== "string") return null;
}
}
return payload as RouteResult;
}
/** Backend #2 of the D-01 ladder — "engine-a". See the module header. */
export function createHttpRoutingSource(opts: HttpRoutingSourceOptions): RoutingSource {
const doFetch = opts.fetchImpl ?? fetch.bind(globalThis);
return {
name: "engine-a",
async route(req: RouteRequest): Promise<RouteResult | null> {
// Slug guard FIRST — a hostile shape never reaches the network (T-05-03).
if (!ID_PATTERN.test(req.destinationId)) return null;
let payload: unknown;
try {
const token = opts.bearer();
const headers: Record<string, string> = { "content-type": "application/json" };
if (token !== null) headers.authorization = `Bearer ${token}`;
const res = await doFetch(opts.endpoint, {
method: "POST",
headers,
body: JSON.stringify({ origin: req.origin, destinationId: req.destinationId }),
});
if (!res.ok) return null; // 401/404/5xx — no answer is an honest null
payload = await res.json(); // throws on a malformed body → the catch below
} catch {
return null; // fail-honest (T-05-04, network edition)
}
if (payload === null) return null; // router-pending over HTTP (D-03)
return validatedResult(payload, req);
},
};
}
+8 -6
View File
@@ -13,18 +13,18 @@
* THE BACKEND LADDER (D-01 supersedes the phase-5 "wasm-router" note): * THE BACKEND LADDER (D-01 supersedes the phase-5 "wasm-router" note):
* #1 "fixture" — createFixtureRoutingSource: the golden-pairs demo * #1 "fixture" — createFixtureRoutingSource: the golden-pairs demo
* corpus (15 committed KL pairs). * corpus (15 committed KL pairs).
* #2 "engine-a" — the Worker-side router built on THIS plan's data * #2 "engine-a" — LIVE (plan 06-04): the Worker-side router built on
* plane: decodeGraph + snapToGraph + routeAstar over an * THIS package's data plane (decodeGraph + snapToGraph +
* R2-hosted CSR graph binary. Engine B (a fully * routeAstar over an R2-hosted CSR graph), consumed
* client-side through createHttpRoutingSource (the
* Bearer-gated POST /api/route seam). Engine B (a fully
* client-side webview router — graph on the phone) is * client-side webview router — graph on the phone) is
* engine A's documented post-v1 evolution (D-02). The * engine A's documented post-v1 evolution (D-02). The
* Valhalla-to-JS port was ruled infeasible and * Valhalla-to-JS port was ruled infeasible and
* geojson-path-finder rejected (demo-grade: no * geojson-path-finder rejected (demo-grade: no
* alternatives/one-ways) — recorded so neither is ever * alternatives/one-ways) — recorded so neither is ever
* re-proposed (D-02). * re-proposed (D-02).
* Plan 04 adds `createHttpRoutingSource` (the /api/route surface that * Engine B remains the documented future rung; nothing else is planned.
* plugs engine A beneath the RoutingSource seam) — deliberately absent
* here.
* *
* The module reads NO environment, NO secrets, and NO network (D-05/D-12): * The module reads NO environment, NO secrets, and NO network (D-05/D-12):
* the whole routing stack is V8-native and dependency-free — fixtures ride * the whole routing stack is V8-native and dependency-free — fixtures ride
@@ -44,6 +44,8 @@ export {
getPairOrigin, getPairOrigin,
getPairs, getPairs,
} from "./fixture-source.ts"; } from "./fixture-source.ts";
export { createHttpRoutingSource } from "./http-source.ts";
export type { HttpRoutingSourceOptions } from "./http-source.ts";
export type { RoadGraph, GraphEdgeInput, GraphInput } from "./graph.ts"; export type { RoadGraph, GraphEdgeInput, GraphInput } from "./graph.ts";
export { export {
decodeGraph, decodeGraph,
+6 -8
View File
@@ -27,8 +27,9 @@
* (0,-90)->left / (0,90)->right cases; the * (0,-90)->left / (0,90)->right cases; the
* compass-consistent reading is pinned here) * compass-consistent reading is pinned here)
* A7 public surface — index.ts exposes the engine-A exports; * A7 public surface — index.ts exposes the engine-A exports;
* createHttpRoutingSource is NOT here yet * createHttpRoutingSource is LIVE (re-pinned
* (plan 04 adds it) * by 06-04, the plan that added it — the
* original pin named this plan as the owner)
* A8 structural — no `new Map(` in astar.ts (allocation-free * A8 structural — no `new Map(` in astar.ts (allocation-free
* hot loop: typed arrays only) * hot loop: typed arrays only)
* *
@@ -251,7 +252,7 @@ test("A6 classifyTurn pins — compass convention: negative bearing delta = left
assert.equal(classifyTurn(350, 80), "right", "90 deg across north is a right"); assert.equal(classifyTurn(350, 80), "right", "90 deg across north is a right");
}); });
test("A7 public surface — index.ts exposes engine A; createHttpRoutingSource is NOT here yet", async () => { test("A7 public surface — index.ts exposes engine A + the engine-a HTTP source (re-pinned 06-04)", async () => {
const mod = await import("../src/index.ts"); const mod = await import("../src/index.ts");
for (const name of [ for (const name of [
"decodeGraph", "decodeGraph",
@@ -266,14 +267,11 @@ test("A7 public surface — index.ts exposes engine A; createHttpRoutingSource i
"TURN_STRAIGHT_MAX_DEG", "TURN_STRAIGHT_MAX_DEG",
"TURN_SLIGHT_MAX_DEG", "TURN_SLIGHT_MAX_DEG",
"TURN_UTURN_MIN_DEG", "TURN_UTURN_MIN_DEG",
"createHttpRoutingSource",
]) { ]) {
assert.notEqual(mod[name], undefined, `index.ts must export ${name}`); assert.notEqual(mod[name], undefined, `index.ts must export ${name}`);
} }
assert.equal( assert.equal(typeof mod.createHttpRoutingSource, "function", "backend #2's client seam is live (D-01, 06-04)");
mod.createHttpRoutingSource,
undefined,
"createHttpRoutingSource belongs to plan 04 — NOT this plan",
);
}); });
test("A8 structural — no `new Map(` in astar.ts (typed arrays only in the hot loop)", () => { test("A8 structural — no `new Map(` in astar.ts (typed arrays only in the hot loop)", () => {