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 +16 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +222 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/src/index.ts +306 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|