@forgebuild/sidon-booking-widget 0.4.17 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ /** One OTA API key, or `{ [slug]: key }` for a site serving marinas on several properties. */
2
+ export type MarinaKeys = string | Record<string, string>;
3
+ export interface MarinaProxyOptions<C extends AstroLikeContext = AstroLikeContext> {
4
+ /** OTA origin, e.g. `https://ota.example.com`. */
5
+ otaBaseUrl: string;
6
+ /** Site rail: one key or slug → key. Give this or `auth`. */
7
+ keys?: MarinaKeys;
8
+ /** Staff rail: upstream credential headers for this request; null → 401 {error:'Not signed in'}. */
9
+ auth?: MarinaProxyAuth<C>;
10
+ /** Upstream timeout; Render cold starts can take most of a minute. */
11
+ timeoutMs?: number;
12
+ }
13
+ export interface AstroLikeContext {
14
+ request: Request;
15
+ params: Record<string, string | undefined>;
16
+ readonly clientAddress: string;
17
+ }
18
+ export type AstroLikeRoute = (context: AstroLikeContext) => Promise<Response>;
19
+ export type MarinaProxyAuth<C extends AstroLikeContext = AstroLikeContext> = (context: C, slug: string) => Promise<Record<string, string> | null> | Record<string, string> | null;
20
+ export declare function createMarinaProxy<C extends AstroLikeContext = AstroLikeContext>({ otaBaseUrl, keys, auth, timeoutMs, }: MarinaProxyOptions<C>): (context: C) => Promise<Response>;
21
+ export interface MarinaClientOptions {
22
+ /** OTA origin, e.g. `https://ota.example.com`. */
23
+ otaBaseUrl: string;
24
+ keys: MarinaKeys;
25
+ /** Waits between 429 retries; injectable so the check doesn't really sleep. */
26
+ sleep?: (ms: number) => Promise<void>;
27
+ /** Per attempt, default 30000: a hung OTA must fail an SSR page, not hold it open. */
28
+ timeoutMs?: number;
29
+ }
30
+ export declare class MarinaClientError extends Error {
31
+ status: number;
32
+ constructor(message: string, status: number);
33
+ }
34
+ export declare function createMarinaClient({ otaBaseUrl, keys, sleep, timeoutMs, }: MarinaClientOptions): {
35
+ /** The configured slugs (`[]` for a single key): a multi-marina site's "other marinas". */
36
+ slugs: string[];
37
+ /** `GET /api/marina/guest/:slug[?from&to]`: the map payload, or null. */
38
+ getMarina: (slug: string, range?: {
39
+ from: string;
40
+ to: string;
41
+ }) => Promise<any>;
42
+ /** `GET /api/marina/guest/:slug/doc-requirements`: OTA's bare array, or null. */
43
+ getDocRequirements: (slug: string) => Promise<any[] | null>;
44
+ };
@@ -0,0 +1,205 @@
1
+ // Server-side half of the widget: an Astro route that relays the browser's
2
+ // /api/marina/guest/* calls to OTA with the site's API key (or, in the OTA
3
+ // dashboard, the signed-in staff member's credentials), so no credential
4
+ // ever reaches the page. Consumers mount it at
5
+ // src/pages/api/marina/guest/[...path].ts.
6
+ //
7
+ // Deliberately one self-contained file: tsc emits it to dist/ unbundled and
8
+ // Astro SSR loads it from node_modules, so a relative runtime import (which
9
+ // would be extensionless) or a node: import (edge runtimes) would break it.
10
+ // Erasable TS only, so scripts/check-astro-proxy.ts runs it under Node's
11
+ // type stripping with no build step. Never import the element entries here:
12
+ // they call customElements.define at import time, which throws on a server.
13
+ const GUEST_PREFIX = '/api/marina/guest/';
14
+ // Vercel's function body limit; OTA's own upload cap (4 MiB) is stricter.
15
+ const MAX_BODY_BYTES = 4_500_000;
16
+ const REQUEST_HEADERS = ['content-type', 'x-file-name', 'x-booking-token', 'user-agent'];
17
+ // Never content-encoding/content-length: Node's fetch has already decoded
18
+ // the body but keeps those headers. Never set-cookie or location.
19
+ const RESPONSE_HEADERS = ['content-type', 'cache-control', 'retry-after', 'ratelimit'];
20
+ // Astro has already percent-decoded params.path (repeatedly, in 6.4+), so a
21
+ // `%` left over, a backslash or a control character is an evasion attempt.
22
+ const BAD_SEGMENT = /[\\%\u0000-\u001f\u007f]/;
23
+ function jsonError(status, error) {
24
+ return new Response(JSON.stringify({ error }), {
25
+ status,
26
+ headers: { 'content-type': 'application/json; charset=utf-8' },
27
+ });
28
+ }
29
+ // Own-property lookup: a slug like `constructor` must not resolve to
30
+ // Object.prototype's function and be sent as a key.
31
+ function keyFor(keys, slug) {
32
+ if (typeof keys === 'string')
33
+ return keys || null;
34
+ return keys && Object.hasOwn(keys, slug) ? keys[slug] || null : null;
35
+ }
36
+ // Astro's getter throws (ClientAddressNotAvailable) on adapters without
37
+ // one; OTA then rate-limits the request in its per-property bucket.
38
+ function clientIp(context) {
39
+ try {
40
+ return context.clientAddress || null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ export function createMarinaProxy({ otaBaseUrl, keys, auth, timeoutMs = 60_000, }) {
47
+ // Fail at startup: with both, which credential reaches OTA is a guess; with
48
+ // neither (e.g. OTA_API_KEY unset) every request would 404 like "no such marina".
49
+ if ((keys === undefined) === (auth === undefined)) {
50
+ throw new Error('createMarinaProxy: give exactly one of keys or auth');
51
+ }
52
+ return async (context) => {
53
+ const { request } = context;
54
+ // Astro dispatches HEAD to the GET export; OTA only needs to see GET.
55
+ const method = request.method === 'HEAD' ? 'GET' : request.method;
56
+ if (method !== 'GET' && method !== 'POST')
57
+ return jsonError(404, 'Not found');
58
+ const path = context.params.path ?? '';
59
+ const segments = path.split('/');
60
+ if (segments.some((s) => s === '' || s === '.' || s === '..' || BAD_SEGMENT.test(s))) {
61
+ return jsonError(404, 'Not found');
62
+ }
63
+ // Belt and braces: whatever the guard missed, the resolved URL must still
64
+ // sit under the guest prefix — the API key and a staff bearer also open
65
+ // staff routes.
66
+ const url = new URL(GUEST_PREFIX + path, otaBaseUrl);
67
+ if (!url.pathname.startsWith(GUEST_PREFIX))
68
+ return jsonError(404, 'Not found');
69
+ url.search = new URL(request.url).search;
70
+ // After the path checks, so a rejected path never costs a token mint.
71
+ let credentials;
72
+ if (auth) {
73
+ const fromAuth = await auth(context, segments[0]);
74
+ if (!fromAuth)
75
+ return jsonError(401, 'Not signed in');
76
+ credentials = fromAuth;
77
+ }
78
+ else {
79
+ const key = keyFor(keys, segments[0]);
80
+ if (!key)
81
+ return jsonError(404, 'Not found');
82
+ credentials = { 'x-api-key': key };
83
+ }
84
+ let body;
85
+ if (method === 'POST') {
86
+ if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) {
87
+ return jsonError(413, 'Request body too large');
88
+ }
89
+ // Buffered rather than streamed: Node's fetch rejects a stream body
90
+ // without duplex:'half', and a buffer gives OTA a Content-Length.
91
+ // ponytail: a chunked body is read fully before this check; Vercel caps
92
+ // it at 4.5 MB before we run, count bytes off the stream if self-hosted.
93
+ body = await request.arrayBuffer();
94
+ if (body.byteLength > MAX_BODY_BYTES)
95
+ return jsonError(413, 'Request body too large');
96
+ }
97
+ // Built from scratch, so cookies, Authorization, X-Forwarded-For and any
98
+ // inbound X-Client-Ip / X-Api-Key / X-Property-Id never reach OTA. The
99
+ // credentials go on after the allowlist so nothing inbound can shadow them.
100
+ const headers = new Headers();
101
+ for (const name of REQUEST_HEADERS) {
102
+ const value = request.headers.get(name);
103
+ if (value !== null)
104
+ headers.set(name, value);
105
+ }
106
+ for (const [name, value] of Object.entries(credentials))
107
+ headers.set(name, value);
108
+ const ip = clientIp(context);
109
+ if (ip)
110
+ headers.set('x-client-ip', ip);
111
+ let upstream;
112
+ try {
113
+ upstream = await fetch(url, {
114
+ method,
115
+ headers,
116
+ body,
117
+ redirect: 'manual',
118
+ signal: AbortSignal.timeout(timeoutMs),
119
+ });
120
+ }
121
+ catch (err) {
122
+ // Caught here because a throw renders Astro's HTML 500 page, which the
123
+ // widget can't parse.
124
+ if (err?.name === 'TimeoutError')
125
+ return jsonError(504, 'Booking service timed out');
126
+ return jsonError(502, 'Booking service unavailable');
127
+ }
128
+ // OTA's guest routes never redirect; a 3xx is infrastructure (e.g. an
129
+ // http→https hop) and its Location must not reach the browser.
130
+ if (upstream.status >= 300 && upstream.status < 400)
131
+ return jsonError(502, 'Booking service unavailable');
132
+ const out = new Headers();
133
+ upstream.headers.forEach((value, name) => {
134
+ if (RESPONSE_HEADERS.includes(name) || name.startsWith('ratelimit-'))
135
+ out.set(name, value);
136
+ });
137
+ // Always a new Response: a fetch() Response's headers are immutable and
138
+ // Clerk's middleware appends to them, which would throw.
139
+ return new Response(upstream.body, { status: upstream.status, headers: out });
140
+ };
141
+ }
142
+ export class MarinaClientError extends Error {
143
+ // A declared field, not a `public status` parameter property: those
144
+ // aren't erasable, and Node's type stripping runs this file as-is.
145
+ status;
146
+ constructor(message, status) {
147
+ super(message);
148
+ this.name = 'MarinaClientError';
149
+ this.status = status;
150
+ }
151
+ }
152
+ // Returns OTA's raw JSON (snake_case, NUMERIC columns as strings): each site
153
+ // maps the few fields it reads; the widget adapts its own copy. No cache:
154
+ // sites wrap this in theirs, and SSR availability must stay fresh.
155
+ export function createMarinaClient({ otaBaseUrl, keys, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), timeoutMs = 30_000, }) {
156
+ // Fail at startup: an unset OTA_API_KEY must not read as "no such marina".
157
+ if (!otaBaseUrl || !keys)
158
+ throw new Error('createMarinaClient: otaBaseUrl and keys are required');
159
+ // null = no such marina (OTA 404, or a slug outside the keys map), so a page can 404.
160
+ async function get(slug, suffix, query = '') {
161
+ const key = keyFor(keys, slug);
162
+ // '.' / '..' survive encodeURIComponent and would resolve out of /guest/, key attached.
163
+ if (!key || slug === '' || slug === '.' || slug === '..')
164
+ return null;
165
+ const url = new URL(GUEST_PREFIX + encodeURIComponent(slug) + suffix + query, otaBaseUrl);
166
+ // Build-time reads carry no X-Client-Ip, so a whole prerender shares one
167
+ // 100/min bucket per key: wait out a 429 rather than fail the build.
168
+ for (let retry = 0;; retry++) {
169
+ let res;
170
+ try {
171
+ res = await fetch(url, { headers: { 'x-api-key': key }, signal: AbortSignal.timeout(timeoutMs) });
172
+ }
173
+ catch (err) {
174
+ // The forwarder's statuses, so a page handles both the same way; a
175
+ // raw DOMException / TypeError would otherwise escape as a 500.
176
+ if (err?.name === 'TimeoutError')
177
+ throw new MarinaClientError('Booking service timed out', 504);
178
+ throw new MarinaClientError('Booking service unavailable', 502);
179
+ }
180
+ if (res.status === 429 && retry < 3) {
181
+ await res.body?.cancel(); // free the connection while we wait
182
+ // NaN when absent or an HTTP-date; OTA's limiter sends seconds.
183
+ const secs = parseInt(res.headers.get('retry-after') ?? '', 10);
184
+ await sleep(Math.min(secs >= 0 ? secs : 5, 30) * 1000);
185
+ continue;
186
+ }
187
+ if (res.status === 404)
188
+ return null;
189
+ if (!res.ok) {
190
+ // OTA errors are {error}; an edge / proxy error page may not be JSON at all.
191
+ const body = await res.json().catch(() => null);
192
+ throw new MarinaClientError(typeof body?.error === 'string' ? body.error : `OTA request failed (${res.status})`, res.status);
193
+ }
194
+ return res.json();
195
+ }
196
+ }
197
+ return {
198
+ /** The configured slugs (`[]` for a single key): a multi-marina site's "other marinas". */
199
+ slugs: typeof keys === 'string' ? [] : Object.keys(keys),
200
+ /** `GET /api/marina/guest/:slug[?from&to]`: the map payload, or null. */
201
+ getMarina: (slug, range) => get(slug, '', range ? `?${new URLSearchParams({ from: range.from, to: range.to })}` : ''),
202
+ /** `GET /api/marina/guest/:slug/doc-requirements`: OTA's bare array, or null. */
203
+ getDocRequirements: (slug) => get(slug, '/doc-requirements'),
204
+ };
205
+ }
@@ -1 +1 @@
1
- export { }
1
+ export * from './elements.js';
@@ -1 +1 @@
1
- export { }
1
+ export * from './elements.js';
@@ -0,0 +1,118 @@
1
+ // Hand-written typings for <sidon-booking-widget> and <sidon-berth-map>.
2
+ // The entry modules only register the elements (side-effect imports), so
3
+ // generated declarations come out as an empty `export {}`. The build copies
4
+ // this file verbatim to dist/elements.d.ts and both entry .d.ts files
5
+ // re-export it (scripts/write-element-types.mjs), which is why it imports
6
+ // nothing: a relative import would dangle in dist/.
7
+ //
8
+ // Keep it in step with the `props` blocks in BookingWidget.svelte and
9
+ // BerthMap.svelte and the events they emit. `npm run check` skips .d.ts
10
+ // files (skipLibCheck), so check an edit with:
11
+ // npx tsc --ignoreConfig --noEmit --skipLibCheck false --lib es2023,dom src/elements.d.ts
12
+
13
+ /** Peer-marina logo pin. Mirrors OtherMarina in lib/types.ts. */
14
+ export interface OtherMarina {
15
+ name: string;
16
+ lat: number;
17
+ lng: number;
18
+ logoMark: string | null;
19
+ /** Always host-supplied: the package owns no site's routing. */
20
+ href: string | null;
21
+ }
22
+
23
+ /** OTA booking status. An expired booking reports `cancelled`. */
24
+ export type SidonBookingStatus = 'hold' | 'pending' | 'confirmed' | 'declined' | 'cancelled';
25
+
26
+ export interface SidonStepChangeDetail { step: 1 | 2 | 3 }
27
+ /** The booking widget fires this from the wizard only, never in booking-page mode. */
28
+ export interface SidonLoaChangeDetail { loa: number }
29
+ /** `bookingId` is OTA's booking UUID, `marina` the marina slug. */
30
+ export interface SidonBookedDetail { bookingId: string; marina: string }
31
+ export interface SidonBookingStatusDetail { bookingId: string; status: SidonBookingStatus }
32
+ /** A berth label, or null when deselected. */
33
+ export interface SidonBerthSelectedDetail { berthId: string | null }
34
+ /** Dates are `YYYY-MM-DD`; `berth` is a berth label. */
35
+ export interface SidonQuickbookSubmitDetail { from: string | null; to: string | null; length: number; berth: string | null }
36
+
37
+ // Element properties mirror the attributes, camelCased; optional like them.
38
+ export interface SidonBookingWidgetElement extends HTMLElement {
39
+ /** OTA marina slug. */
40
+ marina?: string;
41
+ /** '' (default) = this page's origin, where the forwarder route lives. */
42
+ apiOrigin?: string;
43
+ from?: string;
44
+ to?: string;
45
+ length?: number;
46
+ berth?: string;
47
+ /** en/it/fr/es (first two letters). Unset: the wizard is English and the
48
+ booking page follows the booking's own locale. */
49
+ locale?: string;
50
+ currencySymbol?: string;
51
+ variant?: 'navy' | 'dark';
52
+ /** Present (even empty) = booking-page mode; a value overrides `?booking=`. */
53
+ bookingId?: string | null;
54
+ mapsApiKey?: string;
55
+ mapStyle?: 'chart' | 'coastal';
56
+ mapHeight?: string;
57
+ mapWidth?: string;
58
+ mapGap?: string;
59
+ width?: string;
60
+ logoMark?: string | null;
61
+ marinaName?: string;
62
+ otherMarinas?: OtherMarina[];
63
+ }
64
+
65
+ export interface SidonBerthMapElement extends HTMLElement {
66
+ /** OTA marina slug. */
67
+ marina?: string;
68
+ mapsApiKey?: string;
69
+ /** '' (default) = this page's origin, where the forwarder route lives. */
70
+ apiOrigin?: string;
71
+ mapStyle?: 'chart' | 'coastal';
72
+ locale?: string;
73
+ currencySymbol?: string;
74
+ from?: string;
75
+ to?: string;
76
+ length?: number;
77
+ berth?: string;
78
+ /** 'staff' (the OTA dashboard): every listed berth is clickable and fires
79
+ sidon-berth-selected, occupied berths are painted for [from, to), and
80
+ from/to follow their attributes. No card, availability or boat-length
81
+ checks. Default 'guest'. */
82
+ mode?: 'guest' | 'staff';
83
+ /** Re-reads the map payload. Undefined until the element has mounted
84
+ (a microtask after it is connected), so call `el.refresh?.()`. */
85
+ readonly refresh?: () => void;
86
+ bookPath?: string;
87
+ logoMark?: string | null;
88
+ marinaName?: string;
89
+ otherMarinas?: OtherMarina[];
90
+ mapHeight?: string;
91
+ showWeather?: boolean;
92
+ eyebrow?: string;
93
+ cardTitle?: string;
94
+ intro?: string;
95
+ rangeLabel?: string;
96
+ rangePlaceholder?: string;
97
+ lengthLabel?: string;
98
+ selectedLabel?: string;
99
+ selectedPlaceholder?: string;
100
+ ctaLabel?: string;
101
+ }
102
+
103
+ declare global {
104
+ interface HTMLElementTagNameMap {
105
+ 'sidon-booking-widget': SidonBookingWidgetElement;
106
+ 'sidon-berth-map': SidonBerthMapElement;
107
+ }
108
+ // Dispatched with bubbles + composed, so listeners on any ancestor
109
+ // element get the typed detail too.
110
+ interface HTMLElementEventMap {
111
+ 'sidon-step-change': CustomEvent<SidonStepChangeDetail>;
112
+ 'sidon-loa-change': CustomEvent<SidonLoaChangeDetail>;
113
+ 'sidon-booked': CustomEvent<SidonBookedDetail>;
114
+ 'sidon-booking-status': CustomEvent<SidonBookingStatusDetail>;
115
+ 'sidon-berth-selected': CustomEvent<SidonBerthSelectedDetail>;
116
+ 'sidon-quickbook-submit': CustomEvent<SidonQuickbookSubmitDetail>;
117
+ }
118
+ }