Skip to content

Commit c4ee350

Browse files
committed
feat: serve the v2 Track API from the plugin's store
Registers the accumulated tracks as a Track API provider, so a server carrying SignalK/signalk-server#2995 can answer /signalk/v2/api/tracks from this plugin. Until now that route replied 501, 'No track api provider configured'. The v1 routes stay mounted, so Freeboard-SK keeps working until it moves. Registration is an optional call: older servers offer no registerTrackApiProvider and must still start. The provider reaches the store through a getter rather than capturing it, because start() replaces the store wholesale. It does not unregister in stop() — the server pushes its own unregister onto the plugin's stop handlers. An explicit `to` is exclusive, as parseTrackQuery already has it for v1: a client walking adjacent windows would otherwise get the point at the shared boundary in both. Without one the window ends at now and keeps the newest fix. A calendar-unit resolution is resolved against a fixed UTC reference. Temporal's total() refuses weeks, months and years without a starting point, and the API validates resolution as any positive ISO 8601 duration, so ?resolution=P1W would otherwise have thrown a RangeError out as a 500. maxPoints, simplify, epsilon and properties are accepted and ignored; this provider serves positions and has neither a simplifier nor co-recorded values. That is recorded on TracksRequest. Covered end to end: the plugin is packed, installed into a throwaway server and queried over HTTP. Removing the registration turns the suite back into the 501 it replaces.
1 parent d6dba70 commit c4ee350

7 files changed

Lines changed: 1052 additions & 1 deletion

File tree

src/e2e.test-utils.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ export interface E2EServer {
3131
feed: (context: string, position: [number, number], timestamp?: number, source?: string) => Promise<void>
3232
/** GET a path under /signalk/v1/api and parse the JSON. */
3333
api: (path: string) => Promise<unknown>
34+
/**
35+
* GET a path under /signalk/v2/api, keeping the status.
36+
*
37+
* The status is what distinguishes "no provider registered" (501) from an
38+
* answered query, which is the thing a provider registration test is about.
39+
*/
40+
apiV2: (path: string) => Promise<{ status: number; body: unknown }>
3441
/** The server's own vessel context, as it resolved it. */
3542
selfContext: string
3643
stop: () => void
@@ -149,7 +156,12 @@ export async function startServer(options: E2EOptions = {}): Promise<E2EServer>
149156
try {
150157
const res = await fetch(`${url}/signalk`, { signal: AbortSignal.timeout(2000) })
151158
if (res.ok) {
152-
const self = (await (await fetch(`${url}/signalk/v1/api/self`)).json()) as string
159+
// Bounded like the probe above it: the server has answered /signalk,
160+
// but an unbounded fetch here could still hang past the deadline the
161+
// loop exists to enforce.
162+
const self = (await (
163+
await fetch(`${url}/signalk/v1/api/self`, { signal: AbortSignal.timeout(2000) })
164+
).json()) as string
153165
return {
154166
url,
155167
configDir,
@@ -159,6 +171,10 @@ export async function startServer(options: E2EOptions = {}): Promise<E2EServer>
159171
const r = await fetch(`${url}/signalk/v1/api${path}`)
160172
return r.json()
161173
},
174+
apiV2: async (path: string) => {
175+
const r = await fetch(`${url}/signalk/v2/api${path}`)
176+
return { status: r.status, body: await r.json() }
177+
},
162178
stop,
163179
}
164180
}

src/harness.test-utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import express from 'express'
22
import type { Express } from 'express'
33
import ThePlugin from './index.js'
44
import type { ContextPosition } from './index.js'
5+
import type { TrackApi } from './trackApi.js'
56
import type { Debug, LatLngTuple, Position } from './types.js'
67

78
/**
@@ -42,6 +43,13 @@ export interface TestHarness {
4243
setSelfState: (state: string | undefined) => void
4344
stop: () => void
4445
errors: unknown[][]
46+
/**
47+
* The v2 Track API provider the plugin registered, or undefined when the
48+
* server offered no `registerTrackApiProvider`.
49+
*/
50+
trackProvider: () => TrackApi | undefined
51+
/** How many times the plugin registered a provider. */
52+
registrations: () => number
4553
}
4654

4755
export interface HarnessOptions {
@@ -51,6 +59,11 @@ export interface HarnessOptions {
5159
selfPosition?: LatLngTuple
5260
/** Initial navigation.state for the own vessel. */
5361
selfState?: string
62+
/**
63+
* Stand in for a server without the v2 Track API, to check the plugin still
64+
* starts when `registerTrackApiProvider` is absent.
65+
*/
66+
withoutTrackApi?: boolean
5467
}
5568

5669
export function createHarness(options: HarnessOptions = {}): TestHarness {
@@ -60,6 +73,8 @@ export function createHarness(options: HarnessOptions = {}): TestHarness {
6073
let selfPosition: LatLngTuple | undefined = options.selfPosition
6174
let selfState: string | undefined = options.selfState
6275
const selfContext = options.selfContext ?? SELF_CONTEXT
76+
let trackProvider: TrackApi | undefined
77+
let registrations = 0
6378

6479
const debug: Debug = Object.assign(() => undefined, { enabled: false })
6580

@@ -68,6 +83,14 @@ export function createHarness(options: HarnessOptions = {}): TestHarness {
6883
error: (...args: unknown[]) => errors.push(args),
6984
setPluginStatus: (msg: string) => statuses.push(msg),
7085
selfContext,
86+
...(options.withoutTrackApi
87+
? {}
88+
: {
89+
registerTrackApiProvider: (provider: TrackApi) => {
90+
trackProvider = provider
91+
registrations += 1
92+
},
93+
}),
7194
// Path-aware: the plugin reads navigation.state as well as position, and a
7295
// stub that answered every path with a position would let a broken state
7396
// lookup pass unnoticed.
@@ -129,5 +152,7 @@ export function createHarness(options: HarnessOptions = {}): TestHarness {
129152
stop: () => plugin.stop(),
130153
errors,
131154
statuses,
155+
trackProvider: () => trackProvider,
156+
registrations: () => registrations,
132157
}
133158
}

src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { Temporal } from '@js-temporal/polyfill'
1717
import type { Request, RequestHandler, Response, Router } from 'express'
1818
import { join } from 'node:path'
1919
import { Tracks as Tracks_ } from './tracks.js'
20+
import { createTrackProvider } from './trackProvider.js'
21+
import type { TrackApi } from './trackApi.js'
2022
import { SqliteTrackStore } from './sqliteStore.js'
2123
import type { TrackStore } from './store.js'
2224
import { DEFAULT_MAX_SPEED_KNOTS, GlitchFilter } from './glitchFilter.js'
@@ -110,6 +112,14 @@ interface App {
110112
getDataDirPath?: () => string
111113
/** Resolves the named provider, or the configured default when omitted. */
112114
getHistoryApi?: (providerId?: string) => Promise<HistoryApi>
115+
/**
116+
* Offer this plugin's tracks to the v2 Track API. Absent on servers older
117+
* than SignalK/signalk-server#2995.
118+
*
119+
* The server unregisters the provider itself when the plugin stops, so
120+
* `stop()` here must not do it a second time.
121+
*/
122+
registerTrackApiProvider?: (provider: TrackApi) => void
113123
config?: {
114124
settings?: {
115125
historyApi?: { defaultProvider?: string }
@@ -703,6 +713,23 @@ export default function ThePlugin(app: App): Plugin {
703713
clearInterval(statusInterval)
704714
})
705715

716+
// Offer the accumulated tracks to the v2 Track API.
717+
//
718+
// The provider reads `tracks` through a getter rather than capturing it,
719+
// because start() replaces the store wholesale and a captured reference
720+
// would keep serving the store a restart was meant to discard.
721+
//
722+
// Deliberately not unregistered in stop(): the server pushes its own
723+
// unregister onto the plugin's stop handlers when this is called, so
724+
// doing it here as well would unregister twice.
725+
app.registerTrackApiProvider?.(
726+
createTrackProvider({
727+
store: () => tracks,
728+
selfContext: () => app.selfContext,
729+
segmentGap: () => segmentGap,
730+
}),
731+
)
732+
706733
// Bootstrap self track from History API (async, non-blocking).
707734
// Only for `history`: a sqlite store already holds what it recorded, and
708735
// refilling it from a provider would duplicate those positions.

src/trackApi.e2e.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
2+
import { startServer } from './e2e.test-utils.js'
3+
import type { E2EServer } from './e2e.test-utils.js'
4+
5+
/**
6+
* The plugin as a registered v2 Track API provider, inside a real server.
7+
*
8+
* This is what the unit suite cannot show: that the server offered
9+
* `registerTrackApiProvider` to a plugin loaded from a packed tarball, that the
10+
* plugin took it, and that a query arriving over HTTP — parsed and validated by
11+
* the server, not by a test stub — reaches this provider and comes back as
12+
* GeoJSON.
13+
*
14+
* Needs a server checkout carrying SignalK/signalk-server#2995 at
15+
* SIGNALK_SERVER_DIR. Run with `npm run test:e2e`.
16+
*/
17+
18+
const CTX = 'vessels.urn:mrn:imo:mmsi:244170002'
19+
const MINUTE = 60_000
20+
21+
interface Feature {
22+
type: string
23+
geometry: { type: string; coordinates: [number, number][][] } | null
24+
properties: {
25+
context: string
26+
isSelf: boolean
27+
providerId?: string
28+
from: string
29+
to: string
30+
bbox?: [number, number, number, number]
31+
pointCount: number
32+
resolution?: string
33+
coordTimes?: string[][]
34+
}
35+
}
36+
37+
interface Collection {
38+
type: string
39+
features: Feature[]
40+
}
41+
42+
let server: E2EServer
43+
let t0: number
44+
45+
beforeAll(async () => {
46+
server = await startServer({ config: { segmentGapMinutes: 5 } })
47+
t0 = Date.now() - 10 * MINUTE
48+
await server.feed(CTX, [60.1, 24.9], t0)
49+
await server.feed(CTX, [60.11, 24.91], t0 + MINUTE)
50+
await server.feed(CTX, [60.12, 24.92], t0 + 2 * MINUTE)
51+
}, 180_000)
52+
53+
afterAll(() => {
54+
server?.stop()
55+
})
56+
57+
describe('the plugin registers as a track provider', () => {
58+
// Without a registration the server answers 501 "No track api provider
59+
// configured", which is what this server does with the plugin absent.
60+
it('answers a v2 query rather than reporting no provider', async () => {
61+
const { status, body } = await server.apiV2(`/tracks?contexts=${CTX}`)
62+
63+
expect(status).toBe(200)
64+
const collection = body as Collection
65+
expect(collection.type).toBe('FeatureCollection')
66+
expect(collection.features).toHaveLength(1)
67+
})
68+
69+
// The server stamps which provider answered, so a fan-out response can be
70+
// attributed. It is added by the server, not by this plugin.
71+
it('is attributed to this plugin', async () => {
72+
const { body } = await server.apiV2(`/tracks?contexts=${CTX}`)
73+
const [feature] = (body as Collection).features
74+
75+
expect(feature!.properties.providerId).toBe('tracks')
76+
})
77+
})
78+
79+
describe('a v2 query through the real HTTP route', () => {
80+
it('returns GeoJSON in lng,lat order', async () => {
81+
const { body } = await server.apiV2(`/tracks?contexts=${CTX}`)
82+
const [feature] = (body as Collection).features
83+
84+
expect(feature!.type).toBe('Feature')
85+
expect(feature!.geometry!.type).toBe('MultiLineString')
86+
// Longitude first, and roughly where the positions were fed.
87+
const [first] = feature!.geometry!.coordinates[0]!
88+
expect(first![0]).toBeCloseTo(24.9, 1)
89+
expect(first![1]).toBeCloseTo(60.1, 1)
90+
expect(feature!.properties.context).toBe(CTX)
91+
expect(feature!.properties.isSelf).toBe(false)
92+
expect(feature!.properties.pointCount).toBeGreaterThanOrEqual(2)
93+
})
94+
95+
it('serves coordTimes when ?times is asked for', async () => {
96+
const { body } = await server.apiV2(`/tracks?contexts=${CTX}&times`)
97+
const [feature] = (body as Collection).features
98+
99+
const segments = feature!.geometry!.coordinates
100+
expect(feature!.properties.coordTimes).toHaveLength(segments.length)
101+
expect(feature!.properties.coordTimes![0]).toHaveLength(segments[0]!.length)
102+
expect(Date.parse(feature!.properties.coordTimes![0]![0]!)).not.toBeNaN()
103+
})
104+
105+
it('omits the geometry for ?geometry=false, keeping the metadata', async () => {
106+
const { body } = await server.apiV2(`/tracks?contexts=${CTX}&geometry=false`)
107+
const [feature] = (body as Collection).features
108+
109+
expect(feature!.geometry).toBeNull()
110+
expect(feature!.properties.pointCount).toBeGreaterThanOrEqual(2)
111+
})
112+
113+
// The server parses bbox as west,south,east,north and hands the provider the
114+
// same order; a swap anywhere along that path shows up here.
115+
it('filters by bbox in GeoJSON order', async () => {
116+
const inside = await server.apiV2(`/tracks?contexts=${CTX}&bbox=24,59,26,61`)
117+
expect((inside.body as Collection).features).toHaveLength(1)
118+
119+
const elsewhere = await server.apiV2(`/tracks?contexts=${CTX}&bbox=130,-35,139,-33`)
120+
expect((elsewhere.body as Collection).features).toHaveLength(0)
121+
})
122+
123+
// duration is resolved into from/to by the server, so this exercises the
124+
// server's parsing and the provider's window handling together.
125+
it('honours a duration window', async () => {
126+
const wide = await server.apiV2(`/tracks?contexts=${CTX}&duration=PT30M`)
127+
expect((wide.body as Collection).features).toHaveLength(1)
128+
129+
// The track ends ~8 minutes ago, so a one-minute window excludes it.
130+
const narrow = await server.apiV2(`/tracks?contexts=${CTX}&duration=PT1M`)
131+
expect((narrow.body as Collection).features).toHaveLength(0)
132+
})
133+
134+
// The server validates `resolution` as any positive ISO 8601 duration, so a
135+
// calendar unit passes validation and reaches the provider. Resolving one
136+
// needs a reference point; without it this came back as a 500.
137+
it('answers a calendar-unit resolution rather than erroring', async () => {
138+
for (const unit of ['P1W', 'P1M', 'P1Y']) {
139+
const { status, body } = await server.apiV2(`/tracks?contexts=${CTX}&resolution=${unit}`)
140+
141+
expect(status).toBe(200)
142+
expect((body as Collection).features).toHaveLength(1)
143+
}
144+
})
145+
146+
it('rejects a malformed query before reaching the provider', async () => {
147+
const { status } = await server.apiV2(`/tracks?contexts=${CTX}&bbox=1,2,3`)
148+
expect(status).toBe(400)
149+
})
150+
})

src/trackApi.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Temporal } from '@js-temporal/polyfill'
2+
3+
/**
4+
* The v2 Track API contract, declared locally.
5+
*
6+
* These mirror `@signalk/server-api/tracks`, and are copied here for the same
7+
* reason the History API types are: depending on the package would pin this
8+
* plugin to a server version, and the subpath is not published yet. Swap the
9+
* imports when it is.
10+
*
11+
* Specified in https://github.com/SignalK/signalk-server/issues/2504 and
12+
* implemented in https://github.com/SignalK/signalk-server/pull/2995.
13+
*/
14+
15+
/** `[west, south, east, north]` — GeoJSON coordinate order. */
16+
export type TrackBoundingBox = [number, number, number, number]
17+
18+
/**
19+
* A request as the server hands it to a provider.
20+
*
21+
* Not every field is honoured here. `maxPoints`, `simplify`, `epsilon` and
22+
* `properties` are accepted by the API and ignored by this provider: it serves
23+
* positions, and has no co-recorded values to attach or geometry simplifier to
24+
* run. A caller gets the full, unsimplified track for the window it asked for,
25+
* which is a superset of what it requested rather than a wrong answer. The
26+
* response echoes `resolution` when one was requested, so a client can tell a
27+
* thinned track from a full one.
28+
*
29+
* Simplification and co-recorded properties are tracked separately; see
30+
* SignalK/tracks.
31+
*/
32+
export interface TracksRequest {
33+
contexts?: string[]
34+
from?: Temporal.Instant
35+
to?: Temporal.Instant
36+
duration?: Temporal.Duration
37+
bbox?: TrackBoundingBox
38+
resolution?: Temporal.Duration
39+
maxPoints?: number
40+
simplify?: boolean
41+
epsilon?: number
42+
times?: boolean
43+
properties?: string[]
44+
geometry?: boolean
45+
}
46+
47+
export interface TrackProperties {
48+
context: string
49+
isSelf: boolean
50+
contextName?: string
51+
from: string
52+
to: string
53+
bbox?: TrackBoundingBox
54+
pointCount: number
55+
resolution?: string
56+
epsilon?: number
57+
coordTimes?: string[][]
58+
appliedProperties?: string[]
59+
values?: Record<string, (number | string | null)[][]>
60+
}
61+
62+
export interface TrackFeature {
63+
type: 'Feature'
64+
geometry: {
65+
type: 'MultiLineString'
66+
/** `[longitude, latitude]` positions, per segment. */
67+
coordinates: [number, number][][]
68+
} | null
69+
properties: TrackProperties
70+
}
71+
72+
export interface TracksResponse {
73+
type: 'FeatureCollection'
74+
features: TrackFeature[]
75+
}
76+
77+
export interface TrackApi {
78+
getTracks(query: TracksRequest): Promise<TracksResponse>
79+
getTrackContexts(query: TracksRequest): Promise<string[]>
80+
}
81+
82+
/** Present on servers that carry the Track API; absent on older ones. */
83+
export interface WithTrackApi {
84+
registerTrackApiProvider?: (provider: TrackApi) => void
85+
}

0 commit comments

Comments
 (0)