Files
TREK/client/src/utils/placesSession.ts
T
Maurice f02a4b5654 feat(maps): bill a place search as one session instead of per keystroke
Google charges autocomplete per request unless the requests carry a session
token. With one, the keystrokes of a single search and the details lookup that
ends it are charged together, which is also how a user experiences it: they
looked up one place.

TREK sent no token at all, so every debounced keystroke was its own billable
request. On a busy trip that is the largest line on the Places bill, and it is
paid by whoever owns the key, which for a self-hosted instance is the person
running it.

A session opens with the first suggestion request and closes when the user picks
a result. It has to close on the abandoned paths too: a token carried into the
next search would merge two searches into one billed session and scope the
suggestions wrongly. So the search box clearing it, the text-search fallback and
the error paths all end it.

The token is validated on the way in. The autocomplete body goes through the Zod
pipe, the details query does not, so the controller shape-checks it there.
Anything that does not match Google's URL-safe 36-character form is dropped
rather than forwarded, which falls back to per-request billing instead of
breaking a lookup.

A cached details hit never reaches Google and so never closes its session. That
is billing-neutral: an unclosed session is charged as a plain autocomplete
session, which is still cheaper than the per-request behaviour it replaces.
2026-08-14 23:42:51 +02:00

48 lines
1.8 KiB
TypeScript

/**
* Google bills place autocomplete per keystroke unless the requests carry a
* session token. With one, the keystrokes and the details lookup that ends the
* search are charged as a single session, which is what a user experiences as
* "I looked up one place".
*
* A session starts with the first suggestion request and ends when the user
* picks a result (or gives up). Reusing a token across two searches would merge
* them into one billed session and return stale-scoped suggestions, so `end()`
* has to run on both paths.
*
* The token has to be URL-safe ASCII and at most 36 characters; a UUID is
* exactly 36 and is what Google's own clients use. `crypto.randomUUID` is
* missing on http:// origins in some browsers, hence the fallback.
*/
export function newSessionToken(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
if (c && typeof c.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
c.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
}
/** One search's worth of token, created lazily and cleared when it is spent. */
export class PlacesSession {
private token: string | null = null;
/** The token for the running search, starting one if none is open. */
current(): string {
if (!this.token) this.token = newSessionToken();
return this.token;
}
/** The token without starting a session: a details lookup that no search led
* to must not open one, or it would be billed as an empty session. */
peek(): string | undefined {
return this.token ?? undefined;
}
/** Call when the search is over, whether it ended in a pick or not. */
end(): void {
this.token = null;
}
}