@sailingnaturali/signalk-currents 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bryan Clark
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,133 @@
1
+ # @sailingnaturali/signalk-currents
2
+
3
+ A generic [SignalK](https://signalk.org/) server plugin that fetches tidal-current
4
+ predictions from the **CHS** (Canadian Hydrographic Service) and **NOAA** tides &
5
+ currents APIs for a configured list of current stations, and:
6
+
7
+ - publishes **`environment.current`** — the interpolated current (`drift` in m/s,
8
+ `setTrue` in radians) at the vessel, using the nearest configured station; and
9
+ - serves a **`/currents`** resource — the full slack / flood / ebb event series for
10
+ every configured station.
11
+
12
+ There are **no hardcoded stations or gates** — the station list is plugin config, so
13
+ it works anywhere CHS or NOAA publishes current predictions.
14
+
15
+ ## How it works
16
+
17
+ On start, and then every `pollMinutes`, the plugin:
18
+
19
+ 1. Fetches each station's events for the next `horizonDays` UTC days (one fetch per
20
+ station-day, cached in memory — per-day predictions are immutable).
21
+ 2. Reads the vessel position (`navigation.position`), picks the nearest configured
22
+ station, interpolates the current for "now", and publishes an `environment.current`
23
+ delta.
24
+ 3. Keeps the per-station event series available at `/currents`.
25
+
26
+ Interpolation is a quarter-sine model between each slack (speed 0) and the adjacent
27
+ flood/ebb maximum: ramping up uses `Vmax·sin(π/2·frac)`, ramping down uses
28
+ `Vmax·cos(π/2·frac)`. `setTrue` is the extremum's configured direction (`floodDir`
29
+ when flooding, `ebbDir` when ebbing).
30
+
31
+ ## Configuration
32
+
33
+ Configured from the SignalK server plugin UI.
34
+
35
+ | Option | Type | Default | Description |
36
+ |--------|------|---------|-------------|
37
+ | `stations` | array | `[]` | Current stations (see below). |
38
+ | `horizonDays` | number | `3` | How many UTC days of predictions to fetch/keep. |
39
+ | `pollMinutes` | number | `60` | How often to refresh and republish. |
40
+
41
+ Each entry in `stations`:
42
+
43
+ | Field | Type | Description |
44
+ |-------|------|-------------|
45
+ | `provider` | `"chs"` \| `"noaa"` | Prediction source. |
46
+ | `stationId` | string | CHS station id, or NOAA station id (e.g. `PUG1717`). |
47
+ | `noaaBin` | number | NOAA current-station bin (NOAA only). |
48
+ | `label` | string | Human-readable name. |
49
+ | `lat` / `lon` | number | Station position (used for nearest-station selection). |
50
+ | `floodDir` | number | Set direction (°true) while flooding. |
51
+ | `ebbDir` | number | Set direction (°true) while ebbing. |
52
+
53
+ ### Example station config
54
+
55
+ Gillard Passage (CHS) and Boundary Pass (NOAA), drawn from our
56
+ [`tide-mcp`](https://github.com/sailingnaturali) passage database:
57
+
58
+ ```json
59
+ {
60
+ "stations": [
61
+ {
62
+ "provider": "chs",
63
+ "stationId": "5dd3064fe0fdc4b9b4be6978",
64
+ "label": "Gillard Passage",
65
+ "lat": 50.3933,
66
+ "lon": -125.1567,
67
+ "floodDir": 160,
68
+ "ebbDir": 340
69
+ },
70
+ {
71
+ "provider": "noaa",
72
+ "stationId": "PUG1717",
73
+ "noaaBin": 35,
74
+ "label": "Boundary Pass",
75
+ "lat": 48.6912,
76
+ "lon": -123.2450,
77
+ "floodDir": 110,
78
+ "ebbDir": 290
79
+ }
80
+ ],
81
+ "horizonDays": 3,
82
+ "pollMinutes": 60
83
+ }
84
+ ```
85
+
86
+ `floodDir` / `ebbDir` are the set (°true) you observe at the station on the flood and
87
+ ebb — fill them from a current atlas or pilot book for your stations.
88
+
89
+ ## Output
90
+
91
+ ### `environment.current` (delta)
92
+
93
+ ```json
94
+ { "drift": 1.23, "setTrue": 2.79 }
95
+ ```
96
+
97
+ - `drift` — current speed in **m/s**.
98
+ - `setTrue` — direction the current sets **toward**, in **radians true**.
99
+
100
+ ### `/currents` resource
101
+
102
+ Mounted at `/plugins/signalk-currents/currents`:
103
+
104
+ ```json
105
+ {
106
+ "stations": [
107
+ {
108
+ "stationId": "5dd3064fe0fdc4b9b4be6978",
109
+ "label": "Gillard Passage",
110
+ "lat": 50.3933,
111
+ "lon": -125.1567,
112
+ "events": [
113
+ { "utc": "2026-06-06T04:14:00.000Z", "kind": "slack", "speedKn": 0 },
114
+ { "utc": "2026-06-06T05:40:00.000Z", "kind": "flood", "speedKn": 4.1 }
115
+ ]
116
+ }
117
+ ]
118
+ }
119
+ ```
120
+
121
+ `kind` is `slack` | `flood` | `ebb`; `speedKn` is the event speed magnitude in knots.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ npm install
127
+ npm test # vitest
128
+ npm run build # tsc -> dist/
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT © 2026 Bryan Clark
@@ -0,0 +1,3 @@
1
+ import { CurrentEvent } from './types';
2
+ export type DayCache = Map<string, CurrentEvent[]>;
3
+ export declare function createCache(): DayCache;
package/dist/cache.js ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCache = createCache;
4
+ function createCache() {
5
+ return new Map();
6
+ }
@@ -0,0 +1,7 @@
1
+ import { CurrentEvent, StationConfig } from './types';
2
+ export declare function nearestStation(lat: number, lon: number, stations: StationConfig[]): StationConfig | undefined;
3
+ export interface CurrentValue {
4
+ drift: number;
5
+ setTrue: number;
6
+ }
7
+ export declare function interpolateCurrent(now: Date, events: CurrentEvent[], station: StationConfig): CurrentValue | undefined;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nearestStation = nearestStation;
4
+ exports.interpolateCurrent = interpolateCurrent;
5
+ const geolib_1 = require("geolib");
6
+ function nearestStation(lat, lon, stations) {
7
+ if (stations.length === 0)
8
+ return undefined;
9
+ return stations.reduce((best, s) => (0, geolib_1.getDistance)({ latitude: lat, longitude: lon }, { latitude: s.lat, longitude: s.lon }) <
10
+ (0, geolib_1.getDistance)({ latitude: lat, longitude: lon }, { latitude: best.lat, longitude: best.lon })
11
+ ? s : best);
12
+ }
13
+ const KN_TO_MS = 0.514444;
14
+ function interpolateCurrent(now, events, station) {
15
+ const t = now.getTime();
16
+ // bracketing consecutive pair e0 <= t <= e1
17
+ let e0, e1;
18
+ for (let i = 0; i < events.length - 1; i++) {
19
+ if (Date.parse(events[i].utc) <= t && t <= Date.parse(events[i + 1].utc)) {
20
+ e0 = events[i];
21
+ e1 = events[i + 1];
22
+ break;
23
+ }
24
+ }
25
+ if (!e0 || !e1)
26
+ return undefined;
27
+ const frac = (t - Date.parse(e0.utc)) / (Date.parse(e1.utc) - Date.parse(e0.utc));
28
+ let speedKn;
29
+ let extremum;
30
+ if (e0.kind === 'slack') {
31
+ speedKn = e1.speedKn * Math.sin((Math.PI / 2) * frac);
32
+ extremum = e1;
33
+ }
34
+ else if (e1.kind === 'slack') {
35
+ speedKn = e0.speedKn * Math.cos((Math.PI / 2) * frac);
36
+ extremum = e0;
37
+ }
38
+ else {
39
+ speedKn = e0.speedKn + (e1.speedKn - e0.speedKn) * frac;
40
+ extremum = e0;
41
+ } // rare flood↔ebb, linear
42
+ const dir = extremum.kind === 'ebb' ? station.ebbDir : station.floodDir;
43
+ return { drift: speedKn * KN_TO_MS, setTrue: (dir * Math.PI) / 180 };
44
+ }
@@ -0,0 +1,4 @@
1
+ import { CurrentEvent, StationConfig } from './types';
2
+ type DayFetcher = (s: StationConfig, dayStart: Date, dayEnd: Date) => Promise<CurrentEvent[]>;
3
+ export declare function stationEvents(station: StationConfig, start: Date, horizonDays: number, cache: Map<string, CurrentEvent[]>, fetcher?: DayFetcher): Promise<CurrentEvent[]>;
4
+ export {};
package/dist/fetch.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stationEvents = stationEvents;
4
+ const chs_1 = require("./sources/chs");
5
+ const noaa_1 = require("./sources/noaa");
6
+ const defaultFetcher = (s, a, b) => s.provider === 'chs'
7
+ ? (0, chs_1.fetchChsEvents)(s.stationId, a, b)
8
+ : (0, noaa_1.fetchNoaaEvents)(s.stationId, s.noaaBin ?? 0, a, b);
9
+ function utcDays(start, n) {
10
+ const base = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()));
11
+ return Array.from({ length: n }, (_, i) => new Date(base.getTime() + i * 86400000).toISOString().slice(0, 10));
12
+ }
13
+ async function stationEvents(station, start, horizonDays, cache, fetcher = defaultFetcher) {
14
+ const out = [];
15
+ for (const day of utcDays(start, horizonDays)) {
16
+ const key = `${station.provider}:${station.stationId}:${day}`;
17
+ let events = cache.get(key);
18
+ if (!events) {
19
+ const dayStart = new Date(`${day}T00:00:00Z`);
20
+ const dayEnd = new Date(dayStart.getTime() + 86400000);
21
+ events = await fetcher(station, dayStart, dayEnd);
22
+ cache.set(key, events);
23
+ }
24
+ out.push(...events);
25
+ }
26
+ out.sort((a, b) => a.utc.localeCompare(b.utc));
27
+ return out;
28
+ }
@@ -0,0 +1,3 @@
1
+ import { Plugin, ServerAPI } from '@signalk/server-api';
2
+ declare const _default: (app: ServerAPI) => Plugin;
3
+ export = _default;
package/dist/index.js ADDED
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ const cache_1 = require("./cache");
3
+ const fetch_1 = require("./fetch");
4
+ const calculations_1 = require("./calculations");
5
+ const routes_1 = require("./routes");
6
+ module.exports = function (app) {
7
+ // Cache of per-day events keyed by `provider:station:YYYY-MM-DD`, and the
8
+ // current per-station series exposed via /currents. Both live for the
9
+ // lifetime of the plugin process and are read by the route handler.
10
+ const cache = (0, cache_1.createCache)();
11
+ const series = new Map();
12
+ let timer;
13
+ const plugin = {
14
+ id: 'signalk-currents',
15
+ name: 'Tidal currents (CHS/NOAA)',
16
+ description: 'Publishes tidal-current predictions to environment.current and a /currents resource.',
17
+ schema: {
18
+ type: 'object',
19
+ properties: {
20
+ stations: {
21
+ type: 'array', title: 'Current stations',
22
+ items: { type: 'object', required: ['provider', 'stationId', 'label', 'lat', 'lon', 'floodDir', 'ebbDir'],
23
+ properties: {
24
+ provider: { type: 'string', enum: ['chs', 'noaa'] },
25
+ stationId: { type: 'string' }, noaaBin: { type: 'number' },
26
+ label: { type: 'string' }, lat: { type: 'number' }, lon: { type: 'number' },
27
+ floodDir: { type: 'number', title: 'Flood set (°true)' },
28
+ ebbDir: { type: 'number', title: 'Ebb set (°true)' },
29
+ } },
30
+ },
31
+ horizonDays: { type: 'number', default: 3 },
32
+ pollMinutes: { type: 'number', default: 60 },
33
+ },
34
+ },
35
+ start(options) {
36
+ const stations = options.stations ?? [];
37
+ const horizonDays = options.horizonDays ?? 3;
38
+ const pollMinutes = options.pollMinutes ?? 60;
39
+ async function refresh() {
40
+ try {
41
+ const now = new Date();
42
+ // Refresh each station's series from the cached day fetch. Isolate
43
+ // per-station failures so one bad CHS/NOAA response can't blank the
44
+ // others (or skip the environment.current publish) for the whole cycle.
45
+ for (const station of stations) {
46
+ try {
47
+ const events = await (0, fetch_1.stationEvents)(station, now, horizonDays, cache);
48
+ series.set(station.stationId, { station, events });
49
+ }
50
+ catch (e) {
51
+ app.error(`station ${station.label} fetch failed: ${e.message}`);
52
+ }
53
+ }
54
+ // Read the vessel's position (mirrors signalk-tides: reads the
55
+ // `.value` of navigation.position via getSelfPath).
56
+ const position = app.getSelfPath('navigation.position.value');
57
+ if (!position) {
58
+ app.debug('No position available; skipping environment.current publish');
59
+ app.setPluginStatus(`Fetched ${series.size} station(s); awaiting position`);
60
+ return;
61
+ }
62
+ const station = (0, calculations_1.nearestStation)(position.latitude, position.longitude, stations);
63
+ const entry = station ? series.get(station.stationId) : undefined;
64
+ if (!station || !entry) {
65
+ app.setPluginStatus('No station near vessel position');
66
+ return;
67
+ }
68
+ const current = (0, calculations_1.interpolateCurrent)(now, entry.events, station);
69
+ if (!current) {
70
+ app.setPluginStatus(`No current data bracketing now for ${station.label}`);
71
+ return;
72
+ }
73
+ // Publish environment.current as a SignalK delta. handleMessage takes
74
+ // a Partial<Delta>; the path is a branded type so it is cast.
75
+ app.handleMessage(plugin.id, {
76
+ updates: [
77
+ {
78
+ values: [
79
+ {
80
+ path: 'environment.current',
81
+ value: { drift: current.drift, setTrue: current.setTrue },
82
+ },
83
+ ],
84
+ },
85
+ ],
86
+ });
87
+ app.setPluginStatus(`environment.current from ${station.label}: ${current.drift.toFixed(2)} m/s`);
88
+ }
89
+ catch (e) {
90
+ // One bad cycle must never kill the loop.
91
+ app.error(`refresh failed: ${e.message}`);
92
+ app.setPluginError(e.message);
93
+ }
94
+ }
95
+ // Initial fetch, then poll. Errors are swallowed inside refresh().
96
+ refresh();
97
+ timer = setInterval(refresh, pollMinutes * 60_000);
98
+ },
99
+ stop() {
100
+ if (timer) {
101
+ clearInterval(timer);
102
+ timer = undefined;
103
+ }
104
+ },
105
+ // Mounted by the server at /plugins/signalk-currents — so the resource is
106
+ // served at /plugins/signalk-currents/currents. This is the typed
107
+ // equivalent of the express-router mounting signalk-tides does via an
108
+ // app.use() cast; registerWithRouter is the supported Plugin API.
109
+ registerWithRouter(router) {
110
+ router.use('/', (0, routes_1.currentsRouter)(() => series));
111
+ },
112
+ };
113
+ return plugin;
114
+ };
@@ -0,0 +1,16 @@
1
+ import { Router } from 'express';
2
+ import { StationConfig, CurrentEvent } from './types';
3
+ export interface StationSeries {
4
+ station: StationConfig;
5
+ events: CurrentEvent[];
6
+ }
7
+ export declare function currentsPayload(series: Map<string, StationSeries>): {
8
+ stations: {
9
+ stationId: string;
10
+ label: string;
11
+ lat: number;
12
+ lon: number;
13
+ events: CurrentEvent[];
14
+ }[];
15
+ };
16
+ export declare function currentsRouter(getSeries: () => Map<string, StationSeries>): Router;
package/dist/routes.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.currentsPayload = currentsPayload;
4
+ exports.currentsRouter = currentsRouter;
5
+ const express_1 = require("express");
6
+ function currentsPayload(series) {
7
+ return {
8
+ stations: [...series.values()].map(s => ({
9
+ stationId: s.station.stationId, label: s.station.label,
10
+ lat: s.station.lat, lon: s.station.lon, events: s.events,
11
+ })),
12
+ };
13
+ }
14
+ // Mirror signalk-tides/src/routes.ts for how the router is registered with `app`.
15
+ function currentsRouter(getSeries) {
16
+ const r = (0, express_1.Router)();
17
+ r.get('/currents', (_req, res) => res.json(currentsPayload(getSeries())));
18
+ return r;
19
+ }
@@ -0,0 +1,2 @@
1
+ import { CurrentEvent } from '../types';
2
+ export declare function fetchChsEvents(stationId: string, start: Date, end: Date, fetchFn?: typeof fetch): Promise<CurrentEvent[]>;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchChsEvents = fetchChsEvents;
4
+ const types_1 = require("../types");
5
+ const CHS_BASE = 'https://api-sine.dfo-mpo.gc.ca/api/v1';
6
+ const KIND = {
7
+ SLACK: 'slack', EXTREMA_FLOOD: 'flood', EXTREMA_EBB: 'ebb',
8
+ };
9
+ const isoZ = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
10
+ async function fetchChsEvents(stationId, start, end, fetchFn = fetch) {
11
+ const params = new URLSearchParams({
12
+ 'time-series-code': 'wcp1-events', from: isoZ(start), to: isoZ(end),
13
+ });
14
+ const resp = await fetchFn(`${CHS_BASE}/stations/${stationId}/data?${params}`);
15
+ if (!resp.ok)
16
+ throw new Error(`CHS ${resp.status}`);
17
+ const out = [];
18
+ for (const row of await resp.json()) {
19
+ const kind = KIND[row.qualifier];
20
+ if (!kind)
21
+ continue;
22
+ out.push((0, types_1.eventFromParts)(row.eventDate, kind, parseFloat(row.value)));
23
+ }
24
+ return out;
25
+ }
@@ -0,0 +1,2 @@
1
+ import { CurrentEvent } from '../types';
2
+ export declare function fetchNoaaEvents(stationId: string, bin: number, start: Date, end: Date, fetchFn?: typeof fetch): Promise<CurrentEvent[]>;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchNoaaEvents = fetchNoaaEvents;
4
+ const types_1 = require("../types");
5
+ const NOAA_BASE = 'https://api.tidesandcurrents.noaa.gov/api/prod/datagetter';
6
+ const ymd = (d) => d.toISOString().slice(0, 10).replace(/-/g, '');
7
+ // NOAA "YYYY-MM-DD HH:MM" is UTC when requested with time_zone=gmt.
8
+ function parseNoaaTime(s) {
9
+ return new Date(s.replace(' ', 'T') + 'Z').toISOString();
10
+ }
11
+ async function fetchNoaaEvents(stationId, bin, start, end, fetchFn = fetch) {
12
+ const params = new URLSearchParams({
13
+ product: 'currents_predictions', interval: 'MAX_SLACK', time_zone: 'gmt',
14
+ units: 'english', format: 'json', application: 'signalk-currents',
15
+ station: stationId, bin: String(bin), begin_date: ymd(start), end_date: ymd(end),
16
+ });
17
+ const resp = await fetchFn(`${NOAA_BASE}?${params}`);
18
+ if (!resp.ok)
19
+ throw new Error(`NOAA ${resp.status}`);
20
+ const cp = (await resp.json())?.current_predictions?.cp ?? [];
21
+ const out = [];
22
+ for (const row of cp) {
23
+ const kind = String(row.Type ?? '').toLowerCase();
24
+ if (kind !== 'slack' && kind !== 'flood' && kind !== 'ebb')
25
+ continue;
26
+ out.push((0, types_1.eventFromParts)(parseNoaaTime(row.Time), kind, parseFloat(row.Velocity_Major)));
27
+ }
28
+ return out;
29
+ }
@@ -0,0 +1,17 @@
1
+ export type CurrentKind = 'slack' | 'flood' | 'ebb';
2
+ export interface CurrentEvent {
3
+ utc: string;
4
+ kind: CurrentKind;
5
+ speedKn: number;
6
+ }
7
+ export interface StationConfig {
8
+ provider: 'chs' | 'noaa';
9
+ stationId: string;
10
+ noaaBin?: number;
11
+ label: string;
12
+ lat: number;
13
+ lon: number;
14
+ floodDir: number;
15
+ ebbDir: number;
16
+ }
17
+ export declare function eventFromParts(utc: string, kind: CurrentKind, speed: number): CurrentEvent;
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.eventFromParts = eventFromParts;
4
+ function eventFromParts(utc, kind, speed) {
5
+ return { utc: new Date(utc).toISOString(), kind, speedKn: Math.abs(speed) };
6
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@sailingnaturali/signalk-currents",
3
+ "version": "0.1.0",
4
+ "description": "Publish CHS/NOAA tidal-current predictions to SignalK — environment.current + a /currents resource, for a configured station list.",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "test": "vitest run",
9
+ "prepare": "npm run build"
10
+ },
11
+ "keywords": ["signalk-node-server-plugin", "signalk-category-utility", "tidal-currents", "currents", "marine"],
12
+ "author": "Bryan Clark",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/sailingnaturali/signalk-currents.git"
17
+ },
18
+ "homepage": "https://github.com/sailingnaturali/signalk-currents#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/sailingnaturali/signalk-currents/issues"
21
+ },
22
+ "files": ["dist"],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "dependencies": {
30
+ "@signalk/server-api": "^2.0.0",
31
+ "express": "^4.19.0",
32
+ "geolib": "^3.3.4"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.4.0",
36
+ "vitest": "^2.0.0",
37
+ "@types/express": "^4.17.0"
38
+ }
39
+ }