mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
f02a4b5654
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.
48 lines
1.8 KiB
TypeScript
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;
|
|
}
|
|
}
|