perigee-tides 1.0.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Perigee JavaScript SDK are documented here. Registry
4
+ versions and `sdk-v*` tags are immutable.
5
+
6
+ ## 1.0.1 - 2026-07-10
7
+
8
+ - Initial public release for the Perigee Developer API v1.
9
+ - Added dependency-free ESM support for Node 20+, browsers, Deno, Bun, and edge
10
+ runtimes with standards-compatible `fetch`.
11
+ - Added typed station, tide-prediction, trip-health, best-window,
12
+ evaluate-rules, and material-change operations.
13
+ - Added bounded retries, `Retry-After` handling, request/quota metadata, and
14
+ structured API, transport, and protocol errors.
15
+ - Retry and wrap response-body stream interruptions that occur after HTTP
16
+ headers arrive.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Cardin LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # Perigee JavaScript SDK
2
+
3
+ The official, dependency-free JavaScript and TypeScript client for the
4
+ [Perigee Developer API](https://perigeetides.com/developers).
5
+
6
+ It is an ESM package for Node 20+, browsers, Deno, Bun, edge runtimes, and
7
+ other environments with standards-compatible `fetch`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install perigee-tides
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ Public station and tide-prediction calls do not require a key:
18
+
19
+ ```ts
20
+ import { PerigeeClient } from "perigee-tides";
21
+
22
+ const perigee = new PerigeeClient();
23
+ const stations = await perigee.listStations({
24
+ q: "Port Aransas",
25
+ state: "TX",
26
+ limit: 5,
27
+ });
28
+ const predictions = await perigee.tidePredictions("8775237", {
29
+ hours: 24,
30
+ interval: "hilo",
31
+ });
32
+
33
+ console.log(stations.data);
34
+ console.log(predictions.requestId);
35
+ ```
36
+
37
+ Authenticated decision calls use a server-side environment variable:
38
+
39
+ ```ts
40
+ import { PerigeeClient } from "perigee-tides";
41
+
42
+ const perigee = new PerigeeClient({
43
+ apiKey: process.env.PERIGEE_API_KEY,
44
+ });
45
+
46
+ const { data, requestId, monthlyRemaining } = await perigee.bestWindow({
47
+ stationId: "9414290",
48
+ activity: "fishing",
49
+ candidates: [
50
+ { localDate: "2026-07-10", localTime: "06:00" },
51
+ { localDate: "2026-07-10", localTime: "12:00" },
52
+ ],
53
+ });
54
+
55
+ console.log(data.decision, requestId, monthlyRemaining);
56
+ ```
57
+
58
+ Other decision recipes use the same client:
59
+
60
+ ```ts
61
+ const evaluation = await perigee.evaluateRules({
62
+ stationId: "9414290",
63
+ activity: "boating",
64
+ localDate: "2026-07-10",
65
+ localTime: "09:00",
66
+ rules: [
67
+ {
68
+ id: "confidence",
69
+ kind: "minimum_confidence",
70
+ minimum: "moderate",
71
+ },
72
+ ],
73
+ });
74
+
75
+ const change = await perigee.materialChange({
76
+ stationId: "9414290",
77
+ activity: "boating",
78
+ localDate: "2026-07-10",
79
+ localTime: "09:00",
80
+ baseline: {
81
+ decisionId: "previous-decision-id",
82
+ evaluatedAt: "2026-07-09T12:00:00.000Z",
83
+ state: "normal",
84
+ confidence: "high",
85
+ reasonCodes: ["signal.tide"],
86
+ sources: [
87
+ {
88
+ id: "tide-prediction",
89
+ freshness: "fresh",
90
+ sourceAt: "2026-07-09T11:00:00.000Z",
91
+ },
92
+ ],
93
+ },
94
+ });
95
+ ```
96
+
97
+ ## Responses and errors
98
+
99
+ Every successful call returns a typed envelope with `data`, `requestId`,
100
+ `rateRemaining`, `monthlyRemaining`, and `quotaWarning`.
101
+
102
+ ```ts
103
+ import {
104
+ PerigeeApiError,
105
+ PerigeeProtocolError,
106
+ PerigeeTransportError,
107
+ } from "perigee-tides";
108
+
109
+ try {
110
+ await perigee.tripHealth({
111
+ stationId: "9414290",
112
+ activity: "fishing",
113
+ localDate: "2026-07-10",
114
+ localTime: "07:00",
115
+ });
116
+ } catch (error) {
117
+ if (error instanceof PerigeeApiError) {
118
+ console.error(
119
+ error.status,
120
+ error.code,
121
+ error.requestId,
122
+ error.retryAfterSeconds,
123
+ );
124
+ } else if (error instanceof PerigeeTransportError) {
125
+ console.error(`No response after ${error.attempts} attempts`);
126
+ } else if (error instanceof PerigeeProtocolError) {
127
+ console.error(error.status, error.requestId);
128
+ }
129
+ }
130
+ ```
131
+
132
+ The client retries network failures, HTTP 429, and HTTP 5xx responses with
133
+ bounded exponential backoff. `Retry-After` is honored up to a ten-second
134
+ client-side cap.
135
+
136
+ ```ts
137
+ const perigee = new PerigeeClient({
138
+ apiKey: process.env.PERIGEE_API_KEY,
139
+ timeoutMs: 10_000,
140
+ maxRetries: 2,
141
+ });
142
+ ```
143
+
144
+ Do not bundle an API key into browser JavaScript, a mobile binary, logs, or
145
+ source control. Proxy authenticated requests through your own server. The
146
+ SDK never reads a key from a browser global; it only sends the value supplied
147
+ to the constructor.
148
+
149
+ See the [OpenAPI document](https://perigeetides.com/openapi.json) for complete
150
+ response schemas and the
151
+ [developer status page](https://perigeetides.com/developer/status) during an
152
+ incident. Use the public [help and support surface](https://perigeetides.com/help)
153
+ for questions or issue reports.
154
+
155
+ ## Release history
156
+
157
+ SDK releases follow semantic versioning and published registry versions are
158
+ immutable. See the packaged [changelog](./CHANGELOG.md) before upgrading.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Perigee Developer API v1 client.
3
+ * Surface coverage is checked against /openapi.json in the root test suite.
4
+ */
5
+ export interface PerigeeClientOptions {
6
+ apiKey?: string;
7
+ baseUrl?: string;
8
+ timeoutMs?: number;
9
+ maxRetries?: number;
10
+ fetch?: typeof fetch;
11
+ }
12
+ export declare class PerigeeApiError extends Error {
13
+ readonly status: number;
14
+ readonly code: string | number | null;
15
+ readonly requestId: string | null;
16
+ readonly retryAfterSeconds: number | null;
17
+ constructor(message: string, status: number, code: string | number | null, requestId: string | null, retryAfterSeconds: number | null);
18
+ }
19
+ export declare class PerigeeTransportError extends Error {
20
+ readonly attempts: number;
21
+ constructor(message: string, attempts: number, cause: unknown);
22
+ }
23
+ export declare class PerigeeProtocolError extends Error {
24
+ readonly status: number;
25
+ readonly requestId: string | null;
26
+ constructor(message: string, status: number, requestId: string | null, cause?: unknown);
27
+ }
28
+ export interface TripHealthInput {
29
+ stationId: string;
30
+ activity: "fishing" | "boating" | "surf-paddle" | "beachcombing" | "photography" | "coastal-living";
31
+ localDate: string;
32
+ localTime: string;
33
+ }
34
+ export interface PerigeeResponse<T> {
35
+ data: T;
36
+ requestId: string | null;
37
+ rateRemaining: number | null;
38
+ monthlyRemaining: number | null;
39
+ quotaWarning: string | null;
40
+ }
41
+ export declare class PerigeeClient {
42
+ private readonly apiKey;
43
+ private readonly baseUrl;
44
+ private readonly timeoutMs;
45
+ private readonly maxRetries;
46
+ private readonly fetcher;
47
+ constructor(options?: PerigeeClientOptions);
48
+ private request;
49
+ listStations(query?: {
50
+ q?: string;
51
+ state?: string;
52
+ limit?: number;
53
+ }): Promise<PerigeeResponse<{
54
+ count: number;
55
+ stations: unknown[];
56
+ }>>;
57
+ tidePredictions(stationId: string, query?: {
58
+ hours?: number;
59
+ interval?: string;
60
+ datum?: string;
61
+ }): Promise<PerigeeResponse<unknown>>;
62
+ tripHealth(input: TripHealthInput): Promise<PerigeeResponse<{
63
+ decision: unknown;
64
+ }>>;
65
+ bestWindow(input: Omit<TripHealthInput, "localDate" | "localTime"> & {
66
+ candidates: Array<Pick<TripHealthInput, "localDate" | "localTime">>;
67
+ }): Promise<PerigeeResponse<{
68
+ decision: unknown;
69
+ }>>;
70
+ evaluateRules(input: TripHealthInput & {
71
+ rules: unknown[];
72
+ }): Promise<PerigeeResponse<{
73
+ evaluation: unknown;
74
+ }>>;
75
+ materialChange(input: TripHealthInput & {
76
+ baseline: unknown;
77
+ }): Promise<PerigeeResponse<{
78
+ change: unknown;
79
+ }>>;
80
+ }
81
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,qBAAa,eAAgB,SAAQ,KAAK;IAGtC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI;gBAJzC,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,EAC5B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,iBAAiB,EAAE,MAAM,GAAG,IAAI;CAK5C;AAED,qBAAa,qBAAsB,SAAQ,KAAK;IAG5C,QAAQ,CAAC,QAAQ,EAAE,MAAM;gBADzB,OAAO,EAAE,MAAM,EACN,QAAQ,EAAE,MAAM,EACzB,KAAK,EAAE,OAAO;CAKjB;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAG3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;gBAFjC,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAAG,IAAI,EACjC,KAAK,CAAC,EAAE,OAAO;CAKlB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EACJ,SAAS,GACT,SAAS,GACT,aAAa,GACb,cAAc,GACd,aAAa,GACb,gBAAgB,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,IAAI,EAAE,CAAC,CAAC;IACR,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAgDD,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqB;IAC5C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;gBAE3B,OAAO,GAAE,oBAAyB;YAiChC,OAAO;IA4FrB,YAAY,CAAC,KAAK,GAAE;QAAE,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO;eAMxC,MAAM;kBAAY,OAAO,EAAE;;IAK1D,eAAe,CACb,SAAS,EAAE,MAAM,EACjB,KAAK,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO;IAYnE,UAAU,CAAC,KAAK,EAAE,eAAe;kBACC,OAAO;;IAMzC,UAAU,CAAC,KAAK,EAAE,IAAI,CAAC,eAAe,EAAE,WAAW,GAAG,WAAW,CAAC,GAAG;QACnE,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,eAAe,EAAE,WAAW,GAAG,WAAW,CAAC,CAAC,CAAC;KACrE;kBACiC,OAAO;;IAOzC,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG;QAAE,KAAK,EAAE,OAAO,EAAE,CAAA;KAAE;oBACvB,OAAO;;IAO3C,cAAc,CAAC,KAAK,EAAE,eAAe,GAAG;QAAE,QAAQ,EAAE,OAAO,CAAA;KAAE;gBAC7B,OAAO;;CAMxC"}
package/dist/index.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Perigee Developer API v1 client.
3
+ * Surface coverage is checked against /openapi.json in the root test suite.
4
+ */
5
+ export class PerigeeApiError extends Error {
6
+ status;
7
+ code;
8
+ requestId;
9
+ retryAfterSeconds;
10
+ constructor(message, status, code, requestId, retryAfterSeconds) {
11
+ super(message);
12
+ this.status = status;
13
+ this.code = code;
14
+ this.requestId = requestId;
15
+ this.retryAfterSeconds = retryAfterSeconds;
16
+ this.name = "PerigeeApiError";
17
+ }
18
+ }
19
+ export class PerigeeTransportError extends Error {
20
+ attempts;
21
+ constructor(message, attempts, cause) {
22
+ super(message, { cause });
23
+ this.attempts = attempts;
24
+ this.name = "PerigeeTransportError";
25
+ }
26
+ }
27
+ export class PerigeeProtocolError extends Error {
28
+ status;
29
+ requestId;
30
+ constructor(message, status, requestId, cause) {
31
+ super(message, cause === undefined ? undefined : { cause });
32
+ this.status = status;
33
+ this.requestId = requestId;
34
+ this.name = "PerigeeProtocolError";
35
+ }
36
+ }
37
+ function numericHeader(headers, name) {
38
+ const value = headers.get(name);
39
+ if (value === null)
40
+ return null;
41
+ const parsed = Number(value);
42
+ return Number.isFinite(parsed) ? parsed : null;
43
+ }
44
+ function retryAfterSeconds(headers) {
45
+ const value = headers.get("retry-after");
46
+ if (value === null)
47
+ return null;
48
+ const seconds = Number(value);
49
+ if (Number.isFinite(seconds) && seconds >= 0)
50
+ return seconds;
51
+ const timestamp = Date.parse(value);
52
+ if (Number.isNaN(timestamp))
53
+ return null;
54
+ return Math.max(0, Math.ceil((timestamp - Date.now()) / 1_000));
55
+ }
56
+ function retryDelayMs(attempt, retryAfter) {
57
+ const seconds = retryAfter ?? 2 ** attempt;
58
+ return Math.min(10_000, Math.max(0, seconds * 1_000));
59
+ }
60
+ function sleep(ms) {
61
+ return new Promise((resolve) => setTimeout(resolve, ms));
62
+ }
63
+ function apiError(body) {
64
+ if (!body || typeof body !== "object" || !("error" in body)) {
65
+ return { code: null, message: null };
66
+ }
67
+ const error = body.error;
68
+ if (typeof error === "string")
69
+ return { code: null, message: error };
70
+ if (!error || typeof error !== "object") {
71
+ return { code: null, message: null };
72
+ }
73
+ const code = "code" in error ? error.code : null;
74
+ const message = "message" in error ? error.message : null;
75
+ return {
76
+ code: typeof code === "string" || typeof code === "number" ? code : null,
77
+ message: typeof message === "string" ? message : null,
78
+ };
79
+ }
80
+ export class PerigeeClient {
81
+ apiKey;
82
+ baseUrl;
83
+ timeoutMs;
84
+ maxRetries;
85
+ fetcher;
86
+ constructor(options = {}) {
87
+ if (!Number.isFinite(options.timeoutMs ?? 30_000) || (options.timeoutMs ?? 30_000) <= 0) {
88
+ throw new Error("timeoutMs must be a positive finite number.");
89
+ }
90
+ if (!Number.isInteger(options.maxRetries ?? 2) || (options.maxRetries ?? 2) < 0) {
91
+ throw new Error("maxRetries must be a non-negative integer.");
92
+ }
93
+ if (options.apiKey !== undefined && !options.apiKey.trim()) {
94
+ throw new Error("apiKey cannot be blank.");
95
+ }
96
+ const baseUrl = new URL(options.baseUrl ?? "https://perigeetides.com");
97
+ if (!["http:", "https:"].includes(baseUrl.protocol) ||
98
+ baseUrl.username ||
99
+ baseUrl.password ||
100
+ baseUrl.search ||
101
+ baseUrl.hash) {
102
+ throw new Error("baseUrl must be an http(s) URL without credentials, query, or fragment.");
103
+ }
104
+ const fetcher = options.fetch ?? globalThis.fetch;
105
+ if (!fetcher) {
106
+ throw new Error("A standards-compatible fetch implementation is required.");
107
+ }
108
+ this.apiKey = options.apiKey;
109
+ this.baseUrl = baseUrl.toString().replace(/\/+$/, "");
110
+ this.timeoutMs = options.timeoutMs ?? 30_000;
111
+ this.maxRetries = options.maxRetries ?? 2;
112
+ this.fetcher = fetcher;
113
+ }
114
+ async request(path, options = {}) {
115
+ if (options.authenticated && !this.apiKey) {
116
+ throw new Error("This operation requires a Perigee API key.");
117
+ }
118
+ for (let attempt = 0;; attempt += 1) {
119
+ const headers = new Headers({ Accept: "application/json" });
120
+ if (this.apiKey)
121
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
122
+ if (options.body !== undefined)
123
+ headers.set("Content-Type", "application/json");
124
+ let response;
125
+ try {
126
+ response = await this.fetcher(`${this.baseUrl}${path}`, {
127
+ method: options.method ?? "GET",
128
+ headers,
129
+ // Never let a credential-bearing request follow a redirect to a
130
+ // different origin. A 3xx is surfaced as an API error instead.
131
+ redirect: "manual",
132
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
133
+ signal: AbortSignal.timeout(this.timeoutMs),
134
+ });
135
+ }
136
+ catch (error) {
137
+ if (attempt < this.maxRetries) {
138
+ await sleep(retryDelayMs(attempt, null));
139
+ continue;
140
+ }
141
+ throw new PerigeeTransportError(`Could not reach Perigee after ${attempt + 1} attempt(s).`, attempt + 1, error);
142
+ }
143
+ const retryAfter = retryAfterSeconds(response.headers);
144
+ if (attempt < this.maxRetries &&
145
+ (response.status === 429 || response.status >= 500)) {
146
+ await response.body?.cancel().catch(() => undefined);
147
+ await sleep(retryDelayMs(attempt, retryAfter));
148
+ continue;
149
+ }
150
+ let body = null;
151
+ let responseText;
152
+ try {
153
+ responseText = await response.text();
154
+ }
155
+ catch (error) {
156
+ if (attempt < this.maxRetries) {
157
+ await response.body?.cancel().catch(() => undefined);
158
+ await sleep(retryDelayMs(attempt, retryAfter));
159
+ continue;
160
+ }
161
+ throw new PerigeeTransportError(`Could not read a complete Perigee response after ${attempt + 1} attempt(s).`, attempt + 1, error);
162
+ }
163
+ if (responseText) {
164
+ try {
165
+ body = JSON.parse(responseText);
166
+ }
167
+ catch (error) {
168
+ if (response.ok) {
169
+ throw new PerigeeProtocolError("Perigee returned a successful response that was not valid JSON.", response.status, response.headers.get("x-request-id"), error);
170
+ }
171
+ }
172
+ }
173
+ if (!response.ok) {
174
+ const error = apiError(body);
175
+ throw new PerigeeApiError(error.message ?? `Perigee returned HTTP ${response.status}.`, response.status, error.code, response.headers.get("x-request-id"), retryAfter);
176
+ }
177
+ return {
178
+ data: body,
179
+ requestId: response.headers.get("x-request-id"),
180
+ rateRemaining: numericHeader(response.headers, "x-ratelimit-remaining"),
181
+ monthlyRemaining: numericHeader(response.headers, "x-quota-month-remaining"),
182
+ quotaWarning: response.headers.get("x-quota-warning"),
183
+ };
184
+ }
185
+ }
186
+ listStations(query = {}) {
187
+ const params = new URLSearchParams(Object.entries(query).flatMap(([key, value]) => value === undefined ? [] : [[key, String(value)]]));
188
+ return this.request(`/api/v1/stations${params.size ? `?${params}` : ""}`);
189
+ }
190
+ tidePredictions(stationId, query = {}) {
191
+ const params = new URLSearchParams(Object.entries(query).flatMap(([key, value]) => value === undefined ? [] : [[key, String(value)]]));
192
+ return this.request(`/api/v1/stations/${encodeURIComponent(stationId)}/predictions${params.size ? `?${params}` : ""}`);
193
+ }
194
+ tripHealth(input) {
195
+ return this.request("/api/v1/decisions/trip-health", {
196
+ method: "POST",
197
+ body: input,
198
+ });
199
+ }
200
+ bestWindow(input) {
201
+ return this.request("/api/v1/decisions/best-window", {
202
+ method: "POST",
203
+ body: input,
204
+ authenticated: true,
205
+ });
206
+ }
207
+ evaluateRules(input) {
208
+ return this.request("/api/v1/decisions/evaluate-rules", {
209
+ method: "POST",
210
+ body: input,
211
+ authenticated: true,
212
+ });
213
+ }
214
+ materialChange(input) {
215
+ return this.request("/api/v1/decisions/material-change", {
216
+ method: "POST",
217
+ body: input,
218
+ authenticated: true,
219
+ });
220
+ }
221
+ }
222
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "perigee-tides",
3
+ "version": "1.0.1",
4
+ "description": "Dependency-free JavaScript client for the Perigee Developer API",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "CHANGELOG.md",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "main": "./dist/index.js",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
26
+ "build": "npm run clean && tsc -p tsconfig.json",
27
+ "test": "node --test tests/*.test.mjs",
28
+ "prepack": "npm run build",
29
+ "pack:check": "npm pack --dry-run",
30
+ "pack:smoke": "node scripts/pack-smoke.mjs",
31
+ "verify": "npm run build && npm test && npm run pack:check && npm run pack:smoke"
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "license": "MIT",
37
+ "author": "Cardin Labs <support@cardinlabs.com> (https://cardinlabs.com)",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/RyanCardin15/perigee.git",
41
+ "directory": "sdks/javascript"
42
+ },
43
+ "homepage": "https://perigeetides.com/developers",
44
+ "bugs": "https://perigeetides.com/help",
45
+ "keywords": [
46
+ "noaa",
47
+ "tides",
48
+ "coastal",
49
+ "marine-weather",
50
+ "decision-api"
51
+ ],
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "devDependencies": {
56
+ "typescript": "~5.9.3"
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Perigee Developer API v1 client.
3
+ * Surface coverage is checked against /openapi.json in the root test suite.
4
+ */
5
+
6
+ export interface PerigeeClientOptions {
7
+ apiKey?: string;
8
+ baseUrl?: string;
9
+ timeoutMs?: number;
10
+ maxRetries?: number;
11
+ fetch?: typeof fetch;
12
+ }
13
+
14
+ export class PerigeeApiError extends Error {
15
+ constructor(
16
+ message: string,
17
+ readonly status: number,
18
+ readonly code: string | number | null,
19
+ readonly requestId: string | null,
20
+ readonly retryAfterSeconds: number | null,
21
+ ) {
22
+ super(message);
23
+ this.name = "PerigeeApiError";
24
+ }
25
+ }
26
+
27
+ export class PerigeeTransportError extends Error {
28
+ constructor(
29
+ message: string,
30
+ readonly attempts: number,
31
+ cause: unknown,
32
+ ) {
33
+ super(message, { cause });
34
+ this.name = "PerigeeTransportError";
35
+ }
36
+ }
37
+
38
+ export class PerigeeProtocolError extends Error {
39
+ constructor(
40
+ message: string,
41
+ readonly status: number,
42
+ readonly requestId: string | null,
43
+ cause?: unknown,
44
+ ) {
45
+ super(message, cause === undefined ? undefined : { cause });
46
+ this.name = "PerigeeProtocolError";
47
+ }
48
+ }
49
+
50
+ export interface TripHealthInput {
51
+ stationId: string;
52
+ activity:
53
+ | "fishing"
54
+ | "boating"
55
+ | "surf-paddle"
56
+ | "beachcombing"
57
+ | "photography"
58
+ | "coastal-living";
59
+ localDate: string;
60
+ localTime: string;
61
+ }
62
+
63
+ export interface PerigeeResponse<T> {
64
+ data: T;
65
+ requestId: string | null;
66
+ rateRemaining: number | null;
67
+ monthlyRemaining: number | null;
68
+ quotaWarning: string | null;
69
+ }
70
+
71
+ function numericHeader(headers: Headers, name: string): number | null {
72
+ const value = headers.get(name);
73
+ if (value === null) return null;
74
+ const parsed = Number(value);
75
+ return Number.isFinite(parsed) ? parsed : null;
76
+ }
77
+
78
+ function retryAfterSeconds(headers: Headers): number | null {
79
+ const value = headers.get("retry-after");
80
+ if (value === null) return null;
81
+ const seconds = Number(value);
82
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds;
83
+ const timestamp = Date.parse(value);
84
+ if (Number.isNaN(timestamp)) return null;
85
+ return Math.max(0, Math.ceil((timestamp - Date.now()) / 1_000));
86
+ }
87
+
88
+ function retryDelayMs(attempt: number, retryAfter: number | null): number {
89
+ const seconds = retryAfter ?? 2 ** attempt;
90
+ return Math.min(10_000, Math.max(0, seconds * 1_000));
91
+ }
92
+
93
+ function sleep(ms: number): Promise<void> {
94
+ return new Promise((resolve) => setTimeout(resolve, ms));
95
+ }
96
+
97
+ function apiError(body: unknown): {
98
+ code: string | number | null;
99
+ message: string | null;
100
+ } {
101
+ if (!body || typeof body !== "object" || !("error" in body)) {
102
+ return { code: null, message: null };
103
+ }
104
+ const error = body.error;
105
+ if (typeof error === "string") return { code: null, message: error };
106
+ if (!error || typeof error !== "object") {
107
+ return { code: null, message: null };
108
+ }
109
+ const code = "code" in error ? error.code : null;
110
+ const message = "message" in error ? error.message : null;
111
+ return {
112
+ code: typeof code === "string" || typeof code === "number" ? code : null,
113
+ message: typeof message === "string" ? message : null,
114
+ };
115
+ }
116
+
117
+ export class PerigeeClient {
118
+ private readonly apiKey: string | undefined;
119
+ private readonly baseUrl: string;
120
+ private readonly timeoutMs: number;
121
+ private readonly maxRetries: number;
122
+ private readonly fetcher: typeof fetch;
123
+
124
+ constructor(options: PerigeeClientOptions = {}) {
125
+ if (!Number.isFinite(options.timeoutMs ?? 30_000) || (options.timeoutMs ?? 30_000) <= 0) {
126
+ throw new Error("timeoutMs must be a positive finite number.");
127
+ }
128
+ if (!Number.isInteger(options.maxRetries ?? 2) || (options.maxRetries ?? 2) < 0) {
129
+ throw new Error("maxRetries must be a non-negative integer.");
130
+ }
131
+ if (options.apiKey !== undefined && !options.apiKey.trim()) {
132
+ throw new Error("apiKey cannot be blank.");
133
+ }
134
+ const baseUrl = new URL(options.baseUrl ?? "https://perigeetides.com");
135
+ if (
136
+ !["http:", "https:"].includes(baseUrl.protocol) ||
137
+ baseUrl.username ||
138
+ baseUrl.password ||
139
+ baseUrl.search ||
140
+ baseUrl.hash
141
+ ) {
142
+ throw new Error(
143
+ "baseUrl must be an http(s) URL without credentials, query, or fragment.",
144
+ );
145
+ }
146
+ const fetcher = options.fetch ?? globalThis.fetch;
147
+ if (!fetcher) {
148
+ throw new Error("A standards-compatible fetch implementation is required.");
149
+ }
150
+ this.apiKey = options.apiKey;
151
+ this.baseUrl = baseUrl.toString().replace(/\/+$/, "");
152
+ this.timeoutMs = options.timeoutMs ?? 30_000;
153
+ this.maxRetries = options.maxRetries ?? 2;
154
+ this.fetcher = fetcher;
155
+ }
156
+
157
+ private async request<T>(
158
+ path: string,
159
+ options: { method?: "GET" | "POST"; body?: unknown; authenticated?: boolean } = {},
160
+ ): Promise<PerigeeResponse<T>> {
161
+ if (options.authenticated && !this.apiKey) {
162
+ throw new Error("This operation requires a Perigee API key.");
163
+ }
164
+ for (let attempt = 0; ; attempt += 1) {
165
+ const headers = new Headers({ Accept: "application/json" });
166
+ if (this.apiKey) headers.set("Authorization", `Bearer ${this.apiKey}`);
167
+ if (options.body !== undefined) headers.set("Content-Type", "application/json");
168
+ let response: Response;
169
+ try {
170
+ response = await this.fetcher(`${this.baseUrl}${path}`, {
171
+ method: options.method ?? "GET",
172
+ headers,
173
+ // Never let a credential-bearing request follow a redirect to a
174
+ // different origin. A 3xx is surfaced as an API error instead.
175
+ redirect: "manual",
176
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
177
+ signal: AbortSignal.timeout(this.timeoutMs),
178
+ });
179
+ } catch (error) {
180
+ if (attempt < this.maxRetries) {
181
+ await sleep(retryDelayMs(attempt, null));
182
+ continue;
183
+ }
184
+ throw new PerigeeTransportError(
185
+ `Could not reach Perigee after ${attempt + 1} attempt(s).`,
186
+ attempt + 1,
187
+ error,
188
+ );
189
+ }
190
+ const retryAfter = retryAfterSeconds(response.headers);
191
+ if (
192
+ attempt < this.maxRetries &&
193
+ (response.status === 429 || response.status >= 500)
194
+ ) {
195
+ await response.body?.cancel().catch(() => undefined);
196
+ await sleep(retryDelayMs(attempt, retryAfter));
197
+ continue;
198
+ }
199
+ let body: unknown = null;
200
+ let responseText: string;
201
+ try {
202
+ responseText = await response.text();
203
+ } catch (error) {
204
+ if (attempt < this.maxRetries) {
205
+ await response.body?.cancel().catch(() => undefined);
206
+ await sleep(retryDelayMs(attempt, retryAfter));
207
+ continue;
208
+ }
209
+ throw new PerigeeTransportError(
210
+ `Could not read a complete Perigee response after ${attempt + 1} attempt(s).`,
211
+ attempt + 1,
212
+ error,
213
+ );
214
+ }
215
+ if (responseText) {
216
+ try {
217
+ body = JSON.parse(responseText) as unknown;
218
+ } catch (error) {
219
+ if (response.ok) {
220
+ throw new PerigeeProtocolError(
221
+ "Perigee returned a successful response that was not valid JSON.",
222
+ response.status,
223
+ response.headers.get("x-request-id"),
224
+ error,
225
+ );
226
+ }
227
+ }
228
+ }
229
+ if (!response.ok) {
230
+ const error = apiError(body);
231
+ throw new PerigeeApiError(
232
+ error.message ?? `Perigee returned HTTP ${response.status}.`,
233
+ response.status,
234
+ error.code,
235
+ response.headers.get("x-request-id"),
236
+ retryAfter,
237
+ );
238
+ }
239
+ return {
240
+ data: body as T,
241
+ requestId: response.headers.get("x-request-id"),
242
+ rateRemaining: numericHeader(response.headers, "x-ratelimit-remaining"),
243
+ monthlyRemaining: numericHeader(response.headers, "x-quota-month-remaining"),
244
+ quotaWarning: response.headers.get("x-quota-warning"),
245
+ };
246
+ }
247
+ }
248
+
249
+ listStations(query: { q?: string; state?: string; limit?: number } = {}) {
250
+ const params = new URLSearchParams(
251
+ Object.entries(query).flatMap(([key, value]) =>
252
+ value === undefined ? [] : [[key, String(value)]],
253
+ ),
254
+ );
255
+ return this.request<{ count: number; stations: unknown[] }>(
256
+ `/api/v1/stations${params.size ? `?${params}` : ""}`,
257
+ );
258
+ }
259
+
260
+ tidePredictions(
261
+ stationId: string,
262
+ query: { hours?: number; interval?: string; datum?: string } = {},
263
+ ) {
264
+ const params = new URLSearchParams(
265
+ Object.entries(query).flatMap(([key, value]) =>
266
+ value === undefined ? [] : [[key, String(value)]],
267
+ ),
268
+ );
269
+ return this.request<unknown>(
270
+ `/api/v1/stations/${encodeURIComponent(stationId)}/predictions${params.size ? `?${params}` : ""}`,
271
+ );
272
+ }
273
+
274
+ tripHealth(input: TripHealthInput) {
275
+ return this.request<{ decision: unknown }>("/api/v1/decisions/trip-health", {
276
+ method: "POST",
277
+ body: input,
278
+ });
279
+ }
280
+
281
+ bestWindow(input: Omit<TripHealthInput, "localDate" | "localTime"> & {
282
+ candidates: Array<Pick<TripHealthInput, "localDate" | "localTime">>;
283
+ }) {
284
+ return this.request<{ decision: unknown }>("/api/v1/decisions/best-window", {
285
+ method: "POST",
286
+ body: input,
287
+ authenticated: true,
288
+ });
289
+ }
290
+
291
+ evaluateRules(input: TripHealthInput & { rules: unknown[] }) {
292
+ return this.request<{ evaluation: unknown }>("/api/v1/decisions/evaluate-rules", {
293
+ method: "POST",
294
+ body: input,
295
+ authenticated: true,
296
+ });
297
+ }
298
+
299
+ materialChange(input: TripHealthInput & { baseline: unknown }) {
300
+ return this.request<{ change: unknown }>("/api/v1/decisions/material-change", {
301
+ method: "POST",
302
+ body: input,
303
+ authenticated: true,
304
+ });
305
+ }
306
+ }