@freighttech/terminal-tracking 0.7.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 +21 -0
- package/README.md +121 -0
- package/dist/generated/entities/terminal_config/index.js +53 -0
- package/dist/generated/entities/terminal_config/index.js.map +7 -0
- package/dist/generated/entities/terminal_event/index.js +59 -0
- package/dist/generated/entities/terminal_event/index.js.map +7 -0
- package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
- package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +26 -0
- package/dist/generated/entities.ids.generated.js.map +7 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/terminal_tracking/acl.js +14 -0
- package/dist/modules/terminal_tracking/acl.js.map +7 -0
- package/dist/modules/terminal_tracking/api/openapi.js +27 -0
- package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/utils.js +12 -0
- package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/ce.js +35 -0
- package/dist/modules/terminal_tracking/ce.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/index.js +4 -0
- package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
- package/dist/modules/terminal_tracking/data/entities.js +337 -0
- package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
- package/dist/modules/terminal_tracking/data/validators.js +98 -0
- package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
- package/dist/modules/terminal_tracking/di.js +39 -0
- package/dist/modules/terminal_tracking/di.js.map +7 -0
- package/dist/modules/terminal_tracking/encryption.js +14 -0
- package/dist/modules/terminal_tracking/encryption.js.map +7 -0
- package/dist/modules/terminal_tracking/events.js +45 -0
- package/dist/modules/terminal_tracking/events.js.map +7 -0
- package/dist/modules/terminal_tracking/i18n/de.json +175 -0
- package/dist/modules/terminal_tracking/i18n/en.json +178 -0
- package/dist/modules/terminal_tracking/i18n/es.json +175 -0
- package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
- package/dist/modules/terminal_tracking/index.js +15 -0
- package/dist/modules/terminal_tracking/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/availability.js +40 -0
- package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/holds.js +240 -0
- package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/logger.js +19 -0
- package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/stops.js +48 -0
- package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/totp.js +41 -0
- package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
- package/dist/modules/terminal_tracking/setup.js +23 -0
- package/dist/modules/terminal_tracking/setup.js.map +7 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
- package/generated/entities/terminal_config/index.ts +36 -0
- package/generated/entities/terminal_event/index.ts +39 -0
- package/generated/entities/terminal_tracking_job/index.ts +28 -0
- package/generated/entities/terminal_vessel_visit/index.ts +30 -0
- package/generated/entities.ids.generated.ts +52 -0
- package/package.json +80 -0
- package/src/index.ts +44 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
- package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
- package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
- package/src/modules/terminal_tracking/acl.ts +10 -0
- package/src/modules/terminal_tracking/api/openapi.ts +28 -0
- package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
- package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
- package/src/modules/terminal_tracking/api/utils.ts +10 -0
- package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
- package/src/modules/terminal_tracking/ce.ts +31 -0
- package/src/modules/terminal_tracking/commands/index.ts +3 -0
- package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
- package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
- package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
- package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
- package/src/modules/terminal_tracking/data/entities.ts +386 -0
- package/src/modules/terminal_tracking/data/validators.ts +113 -0
- package/src/modules/terminal_tracking/di.ts +46 -0
- package/src/modules/terminal_tracking/encryption.ts +12 -0
- package/src/modules/terminal_tracking/events.ts +48 -0
- package/src/modules/terminal_tracking/i18n/de.json +175 -0
- package/src/modules/terminal_tracking/i18n/en.json +178 -0
- package/src/modules/terminal_tracking/i18n/es.json +175 -0
- package/src/modules/terminal_tracking/i18n/pl.json +206 -0
- package/src/modules/terminal_tracking/index.ts +13 -0
- package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
- package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
- package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
- package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
- package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
- package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
- package/src/modules/terminal_tracking/lib/availability.ts +119 -0
- package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
- package/src/modules/terminal_tracking/lib/holds.ts +344 -0
- package/src/modules/terminal_tracking/lib/logger.ts +21 -0
- package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
- package/src/modules/terminal_tracking/lib/stops.ts +124 -0
- package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
- package/src/modules/terminal_tracking/lib/totp.ts +63 -0
- package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
- package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
- package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
- package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
- package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
- package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
- package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
- package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
- package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
- package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
- package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
- package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
- package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
- package/src/modules/terminal_tracking/setup.ts +24 -0
- package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { fetch, type RequestInit as UndiciRequestInit } from 'undici'
|
|
2
|
+
import { proxyDispatcher, type FetchInitWithDispatcher } from './proxy'
|
|
3
|
+
import { terminalLogger } from '../../logger'
|
|
4
|
+
|
|
5
|
+
/** Outcome of an N4 HTTP request: status + the fully-read response body. */
|
|
6
|
+
export type N4Response = { status: number; ok: boolean; text: string }
|
|
7
|
+
|
|
8
|
+
export type N4RequestOptions = {
|
|
9
|
+
/** Terminal proxy URL — egresses through an undici ProxyAgent when set. */
|
|
10
|
+
proxyUrl?: string | null
|
|
11
|
+
/** Per-attempt timeout (whole attempt: connect + headers + body). Default 30s. */
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
/** Extra attempts after the first, on transient network failures. Default 2. */
|
|
14
|
+
retries?: number
|
|
15
|
+
/** Base backoff between attempts (doubles each retry). Default 250ms. */
|
|
16
|
+
retryBackoffMs?: number
|
|
17
|
+
/** Short label for logs (e.g. `"<terminalCode> /unit"`). */
|
|
18
|
+
label?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
22
|
+
const DEFAULT_RETRIES = 2
|
|
23
|
+
const DEFAULT_BACKOFF_MS = 250
|
|
24
|
+
|
|
25
|
+
// undici raises these for a dropped/half-open/timed-out connection — all safe to
|
|
26
|
+
// retry (the request either never reached the server or got no usable response).
|
|
27
|
+
const TRANSIENT_CODES = new Set([
|
|
28
|
+
'UND_ERR_SOCKET',
|
|
29
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
30
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
31
|
+
'UND_ERR_BODY_TIMEOUT',
|
|
32
|
+
'ECONNRESET',
|
|
33
|
+
'ECONNREFUSED',
|
|
34
|
+
'EPIPE',
|
|
35
|
+
'ETIMEDOUT',
|
|
36
|
+
'EAI_AGAIN',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A transient network failure (connection dropped/reset/timed out) — distinct
|
|
41
|
+
* from an HTTP error *status*, which is a real answer and must not be retried.
|
|
42
|
+
*/
|
|
43
|
+
export function isTransientNetworkError(err: unknown): boolean {
|
|
44
|
+
// AbortSignal.timeout() rejects with a TimeoutError DOMException.
|
|
45
|
+
if (err instanceof DOMException && err.name === 'TimeoutError') return true
|
|
46
|
+
const e = err as { code?: string; message?: string; cause?: { code?: string; message?: string } }
|
|
47
|
+
const code = e?.code ?? e?.cause?.code
|
|
48
|
+
if (code && TRANSIENT_CODES.has(code)) return true
|
|
49
|
+
// undici surfaces a mid-flight peer socket close as `TypeError: terminated`
|
|
50
|
+
// (cause: UND_ERR_SOCKET "other side closed"); match on message as a backstop.
|
|
51
|
+
const msg = `${e?.message ?? ''} ${e?.cause?.message ?? ''}`
|
|
52
|
+
return /terminated|other side closed|socket hang ?up|fetch failed/i.test(msg)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Perform one N4 HTTP request and ALWAYS fully read the response body — even on a
|
|
59
|
+
* non-2xx status — returning `{ status, ok, text }`. Callers inspect `status`
|
|
60
|
+
* and parse `text`; they never touch the live response stream.
|
|
61
|
+
*
|
|
62
|
+
* Why drain the body unconditionally: undici holds the keep-alive socket open
|
|
63
|
+
* while a response body is unread. The previous code threw on a non-ok status
|
|
64
|
+
* (e.g. Baltic Hub's Cloudflare allowlist 403) WITHOUT reading the body, leaving
|
|
65
|
+
* a dangling stream. When the peer later closed that socket, undici errored the
|
|
66
|
+
* orphaned body controller with no reader attached, surfacing an *unhandled*
|
|
67
|
+
* `TypeError: terminated`. Under Node's default unhandled-rejection policy that
|
|
68
|
+
* killed the queue-worker process and took the whole server down. Reading the
|
|
69
|
+
* body inside this `try` means any mid-stream close rejects an awaited call here,
|
|
70
|
+
* where it is caught and (when transient) retried — it can never escape.
|
|
71
|
+
*
|
|
72
|
+
* Each attempt is bounded by an AbortSignal timeout, and transient connection
|
|
73
|
+
* drops are retried with exponential backoff so a single Cloudflare/tunnel
|
|
74
|
+
* keep-alive reset doesn't fail an entire poll cycle.
|
|
75
|
+
*/
|
|
76
|
+
export async function n4Request(
|
|
77
|
+
url: string,
|
|
78
|
+
init: Omit<FetchInitWithDispatcher, 'dispatcher' | 'signal'>,
|
|
79
|
+
options: N4RequestOptions = {},
|
|
80
|
+
): Promise<N4Response> {
|
|
81
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
82
|
+
const retries = options.retries ?? DEFAULT_RETRIES
|
|
83
|
+
const backoffMs = options.retryBackoffMs ?? DEFAULT_BACKOFF_MS
|
|
84
|
+
const dispatcher = proxyDispatcher(options.proxyUrl)
|
|
85
|
+
const label = options.label ?? url
|
|
86
|
+
|
|
87
|
+
let lastErr: unknown
|
|
88
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
89
|
+
try {
|
|
90
|
+
// `fetch` is imported from undici (not the global) so the request driver
|
|
91
|
+
// and the `dispatcher` (a ProxyAgent from the SAME undici) belong to one
|
|
92
|
+
// instance. With the global fetch — Node's *built-in* undici — driving a
|
|
93
|
+
// dispatcher from this package's *bundled* undici, a peer socket reset
|
|
94
|
+
// makes the bundled dispatcher call the built-in fetch's onAborted, which
|
|
95
|
+
// throws `TypeError: terminated` from a detached socket-close handler that
|
|
96
|
+
// no try/catch here can see — it escaped as an uncaughtException and killed
|
|
97
|
+
// the worker. Same-instance, that reset rejects the awaited call below,
|
|
98
|
+
// where it is caught and (when transient) retried.
|
|
99
|
+
const res = await fetch(url, {
|
|
100
|
+
...init,
|
|
101
|
+
dispatcher,
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
103
|
+
} as UndiciRequestInit)
|
|
104
|
+
// Drain the body within this try — see the doc comment above. This is the
|
|
105
|
+
// line that prevents the dangling-stream unhandled rejection.
|
|
106
|
+
const text = await res.text()
|
|
107
|
+
return { status: res.status, ok: res.ok, text }
|
|
108
|
+
} catch (err) {
|
|
109
|
+
lastErr = err
|
|
110
|
+
if (attempt < retries && isTransientNetworkError(err)) {
|
|
111
|
+
const wait = backoffMs * 2 ** attempt
|
|
112
|
+
terminalLogger.warn('N4 request transient failure — retrying', {
|
|
113
|
+
label,
|
|
114
|
+
attempt: attempt + 1,
|
|
115
|
+
maxAttempts: retries + 1,
|
|
116
|
+
waitMs: wait,
|
|
117
|
+
message: err instanceof Error ? err.message : String(err),
|
|
118
|
+
})
|
|
119
|
+
await sleep(wait)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// The loop always returns or throws; this satisfies the type checker.
|
|
126
|
+
throw lastErr
|
|
127
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { N4UnitRow } from './types'
|
|
2
|
+
import { DEFAULT_TERMINAL_TIMEZONE, zonedNaiveToUtc } from '../zoned-time'
|
|
3
|
+
import type { TerminalFetchedEvent, ResolvedTerminalConfig } from '../../terminal-adapter'
|
|
4
|
+
import type {
|
|
5
|
+
TerminalEventType,
|
|
6
|
+
TerminalEventClassifierCode,
|
|
7
|
+
TerminalModeOfTransport,
|
|
8
|
+
} from '../../../data/entities'
|
|
9
|
+
|
|
10
|
+
type StateMapping = {
|
|
11
|
+
code: string // canonical N4 transit-state code
|
|
12
|
+
eventType: TerminalEventType
|
|
13
|
+
eventCode: string
|
|
14
|
+
classifier: TerminalEventClassifierCode
|
|
15
|
+
timestampField: 'timeIn' | 'timeOut' | 'loaded'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Maps an N4 friendly T-State (as returned by `/unit`) to a normalized event.
|
|
20
|
+
* The `/unit` response uses friendly names ("Inbound", "Yard", "Loaded",
|
|
21
|
+
* "Departed"); the `S*` codes are the request-side filter values.
|
|
22
|
+
*/
|
|
23
|
+
const STATE_MAP: Record<string, StateMapping> = {
|
|
24
|
+
inbound: { code: 'S20_INBOUND', eventType: 'EQUIPMENT', eventCode: 'GTIN', classifier: 'ACT', timestampField: 'timeIn' },
|
|
25
|
+
ecin: { code: 'S30_ECIN', eventType: 'EQUIPMENT', eventCode: 'GTIN', classifier: 'ACT', timestampField: 'timeIn' },
|
|
26
|
+
yard: { code: 'S40_YARD', eventType: 'EQUIPMENT', eventCode: 'DISC', classifier: 'ACT', timestampField: 'timeIn' },
|
|
27
|
+
loaded: { code: 'S60_LOADED', eventType: 'EQUIPMENT', eventCode: 'LOAD', classifier: 'ACT', timestampField: 'loaded' },
|
|
28
|
+
departed: { code: 'S70_DEPARTED', eventType: 'TRANSPORT', eventCode: 'DEPA', classifier: 'ACT', timestampField: 'timeOut' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Map an eventCode to the semantic module event id (or null if none). */
|
|
32
|
+
export function semanticEventIdFor(eventCode: string): string | null {
|
|
33
|
+
switch (eventCode) {
|
|
34
|
+
case 'GTIN':
|
|
35
|
+
return 'terminal_tracking.equipment.gate_in'
|
|
36
|
+
case 'DISC':
|
|
37
|
+
return 'terminal_tracking.equipment.discharged'
|
|
38
|
+
case 'LOAD':
|
|
39
|
+
return 'terminal_tracking.equipment.loaded'
|
|
40
|
+
case 'DEPA':
|
|
41
|
+
return 'terminal_tracking.transport.departed'
|
|
42
|
+
default:
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildSourceEventId(terminalCode: string, ufvGkey: string, eventCode: string): string {
|
|
48
|
+
return `${terminalCode}:${ufvGkey}:${eventCode}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse an N4 timestamp ("yyyy-mm-dd hh:mm[:ss]"). N4 returns terminal-local
|
|
53
|
+
* time without an offset, so we interpret it in the terminal's timezone
|
|
54
|
+
* (Europe/Warsaw by default) and return the corresponding UTC instant.
|
|
55
|
+
*/
|
|
56
|
+
export function parseN4DateTime(
|
|
57
|
+
value: string | null | undefined,
|
|
58
|
+
timeZone: string = DEFAULT_TERMINAL_TIMEZONE,
|
|
59
|
+
): Date | null {
|
|
60
|
+
if (!value) return null
|
|
61
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value.trim())
|
|
62
|
+
if (!m) return null
|
|
63
|
+
const [, yyyy, mm, dd, hh, mi, ss] = m
|
|
64
|
+
return zonedNaiveToUtc(+yyyy, +mm, +dd, +hh, +mi, ss ? +ss : 0, timeZone)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function inferMode(visitRef: string | null | undefined): TerminalModeOfTransport | null {
|
|
68
|
+
if (!visitRef) return null
|
|
69
|
+
const v = visitRef.toUpperCase()
|
|
70
|
+
if (v.includes('TRUCK')) return 'TRUCK'
|
|
71
|
+
// Rail visit ids look like 'TUX3W31-25_IMP' / '..._EXP'
|
|
72
|
+
if (/_IMP|_EXP|\bW\d/.test(v)) return 'RAIL'
|
|
73
|
+
return 'VESSEL'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The container's direction of travel through the terminal, from the N4
|
|
78
|
+
* `Category` column. Anything else N4 reports (notably "Storage" for empties)
|
|
79
|
+
* is not a direction and normalizes to null.
|
|
80
|
+
*/
|
|
81
|
+
export type TerminalCargoCategory = 'import' | 'export'
|
|
82
|
+
|
|
83
|
+
/** Normalize an N4 `Category` value to a direction (null when not directional). */
|
|
84
|
+
export function parseCargoCategory(rawData: Record<string, unknown> | null | undefined): TerminalCargoCategory | null {
|
|
85
|
+
const category = String(rawData?.['Category'] ?? '').trim().toLowerCase()
|
|
86
|
+
if (category === 'import') return 'import'
|
|
87
|
+
if (category === 'export') return 'export'
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pick the vessel-side visit ref for a leg, by its N4 `Category`:
|
|
93
|
+
* an **Import** leg's vessel is the inbound visit ("I/B Actual Visit"); an
|
|
94
|
+
* **Export** leg's vessel is the outbound visit ("O/B Actual Visit"). The `dir`
|
|
95
|
+
* tells the caller which voyage (inbound vs outbound) to attach. Returns null
|
|
96
|
+
* when the category is unknown or the relevant side is missing.
|
|
97
|
+
*/
|
|
98
|
+
export function pickVesselVisit(
|
|
99
|
+
event: Pick<TerminalFetchedEvent, 'visitRefIn' | 'visitRefOut' | 'rawData'>,
|
|
100
|
+
): { ref: string; dir: 'in' | 'out' } | null {
|
|
101
|
+
const category = parseCargoCategory(event.rawData)
|
|
102
|
+
if (category === 'import') return event.visitRefIn ? { ref: event.visitRefIn, dir: 'in' } : null
|
|
103
|
+
if (category === 'export') return event.visitRefOut ? { ref: event.visitRefOut, dir: 'out' } : null
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Map a single N4 unit-facility-visit row to a normalized terminal event,
|
|
109
|
+
* keyed by the row's current transit state. Returns null when the state is not
|
|
110
|
+
* mappable (e.g. advised-only, retired).
|
|
111
|
+
*/
|
|
112
|
+
export function mapRowToEvent(
|
|
113
|
+
row: N4UnitRow,
|
|
114
|
+
config: ResolvedTerminalConfig,
|
|
115
|
+
): TerminalFetchedEvent | null {
|
|
116
|
+
const stateKey = (row.tState ?? '').trim().toLowerCase()
|
|
117
|
+
const mapping = STATE_MAP[stateKey]
|
|
118
|
+
if (!mapping) return null
|
|
119
|
+
|
|
120
|
+
const eventDateTime =
|
|
121
|
+
parseN4DateTime(row[mapping.timestampField]) ??
|
|
122
|
+
parseN4DateTime(row.timeOut) ??
|
|
123
|
+
parseN4DateTime(row.loaded) ??
|
|
124
|
+
parseN4DateTime(row.timeIn) ??
|
|
125
|
+
new Date()
|
|
126
|
+
|
|
127
|
+
const seals = (row.seals ?? []).map((number) => ({ number, source: config.terminalCode }))
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
source: 'terminal',
|
|
131
|
+
sourceEventId: buildSourceEventId(config.terminalCode, row.ufvGkey, mapping.eventCode),
|
|
132
|
+
eventType: mapping.eventType,
|
|
133
|
+
eventCode: mapping.eventCode,
|
|
134
|
+
eventClassifierCode: mapping.classifier,
|
|
135
|
+
eventDateTime,
|
|
136
|
+
containerNumber: row.unitNbr,
|
|
137
|
+
ufvGkey: row.ufvGkey,
|
|
138
|
+
transitState: mapping.code,
|
|
139
|
+
visitState: row.vState ?? null,
|
|
140
|
+
facilityCode: config.facilityCode ?? null,
|
|
141
|
+
facilityCodeListProvider: config.facilityCodeListProvider ?? null,
|
|
142
|
+
unlocode: config.unlocode ?? null,
|
|
143
|
+
visitRefIn: row.ibActualVisit ?? null,
|
|
144
|
+
visitRefOut: row.obActualVisit ?? null,
|
|
145
|
+
vesselName: null,
|
|
146
|
+
voyageNumber: null,
|
|
147
|
+
modeOfTransport: inferMode(row.obActualVisit) ?? inferMode(row.ibActualVisit),
|
|
148
|
+
seals: seals.length ? seals : null,
|
|
149
|
+
vgmWeightKg: row.vgmWeight ?? null,
|
|
150
|
+
impediments: row.impediments ?? null,
|
|
151
|
+
// The N4 `Loaded` timestamp, parsed independently of the current state so a
|
|
152
|
+
// container that has since departed still carries when it was loaded.
|
|
153
|
+
loadedAt: parseN4DateTime(row.loaded),
|
|
154
|
+
rawData: row.raw,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { N4UnitRow } from '../types'
|
|
2
|
+
import { parseTerminalStops } from '../../../stops'
|
|
3
|
+
|
|
4
|
+
/** Coerce a value that may be a single item or an array into an array. */
|
|
5
|
+
function asArray<T>(v: T | T[] | null | undefined): T[] {
|
|
6
|
+
if (v == null) return []
|
|
7
|
+
return Array.isArray(v) ? v : [v]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toNum(v: string | null | undefined): number | null {
|
|
11
|
+
if (v == null || v === '') return null
|
|
12
|
+
const n = Number(v)
|
|
13
|
+
return Number.isFinite(n) ? n : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function splitList(v: string | null | undefined): string[] | null {
|
|
17
|
+
if (v == null || v === '') return null
|
|
18
|
+
return v
|
|
19
|
+
.split(',')
|
|
20
|
+
.map((s) => s.trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type N4DataTableResponse = {
|
|
25
|
+
'query-response'?: {
|
|
26
|
+
'data-table'?: {
|
|
27
|
+
'@count'?: string
|
|
28
|
+
columns?: { column?: string | string[] }
|
|
29
|
+
rows?: { row?: N4RawRow | N4RawRow[] } | null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type N4RawRow = {
|
|
35
|
+
'@primary-key'?: string
|
|
36
|
+
field?: (string | null) | (string | null)[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse the N4 `/unit` `query-response.data-table` JSON envelope into typed
|
|
41
|
+
* rows. Maps `field[]` onto `columns.column[]` by index, reads `@primary-key`
|
|
42
|
+
* as the Ufv_Gkey, and tolerates single-vs-array collapsing from XML→JSON.
|
|
43
|
+
*/
|
|
44
|
+
export function parseUnitDataTable(body: N4DataTableResponse): N4UnitRow[] {
|
|
45
|
+
const table = body?.['query-response']?.['data-table']
|
|
46
|
+
if (!table) return []
|
|
47
|
+
|
|
48
|
+
const columns = asArray(table.columns?.column).map((c) => String(c))
|
|
49
|
+
const rows = asArray(table.rows?.row ?? undefined)
|
|
50
|
+
|
|
51
|
+
const idx = (header: string): number => columns.indexOf(header)
|
|
52
|
+
const out: N4UnitRow[] = []
|
|
53
|
+
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
const fields = asArray(row.field)
|
|
56
|
+
const get = (header: string): string | null => {
|
|
57
|
+
const i = idx(header)
|
|
58
|
+
if (i < 0 || i >= fields.length) return null
|
|
59
|
+
const v = fields[i]
|
|
60
|
+
return v == null || v === '' ? null : String(v)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const raw: Record<string, string | null> = {}
|
|
64
|
+
columns.forEach((col, i) => {
|
|
65
|
+
raw[col] = fields[i] == null || fields[i] === '' ? null : String(fields[i])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const ufvGkey = row['@primary-key'] ? String(row['@primary-key']) : ''
|
|
69
|
+
const unitNbr = get('Unit Nbr') ?? ''
|
|
70
|
+
if (!ufvGkey || !unitNbr) continue
|
|
71
|
+
|
|
72
|
+
out.push({
|
|
73
|
+
ufvGkey,
|
|
74
|
+
unitNbr,
|
|
75
|
+
tState: get('T-State'),
|
|
76
|
+
vState: get('V-State'),
|
|
77
|
+
category: get('Category'),
|
|
78
|
+
lineOp: get('Line Op'),
|
|
79
|
+
typeIso: get('Type ISO'),
|
|
80
|
+
frghtKind: get('Frght Kind'),
|
|
81
|
+
ibActualVisit: get('I/B Actual Visit'),
|
|
82
|
+
obActualVisit: get('O/B Actual Visit'),
|
|
83
|
+
timeIn: get('Time In'),
|
|
84
|
+
timeOut: get('Time Out'),
|
|
85
|
+
impediments: splitList(get('Unit Impediments')),
|
|
86
|
+
cenNumber: get('CEN Number'),
|
|
87
|
+
vgmWeight: toNum(get('VGM Weight')),
|
|
88
|
+
cargoWtKg: toNum(get('Cargo Wt (kg)')),
|
|
89
|
+
tareWt: toNum(get('Tare Wt')),
|
|
90
|
+
weightKg: toNum(get('Weight (kg)')),
|
|
91
|
+
seals: [get('Seal Nbr1'), get('Seal Nbr2'), get('Seal Nbr3'), get('Seal Nbr4')].filter(
|
|
92
|
+
(s): s is string => !!s,
|
|
93
|
+
),
|
|
94
|
+
dskNumber: get('DSK Number'),
|
|
95
|
+
loaded: get('Loaded'),
|
|
96
|
+
stops: parseTerminalStops(raw),
|
|
97
|
+
raw,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { N4DataTableResponse } from './data-table'
|
|
2
|
+
import type { N4VesselRow } from '../types'
|
|
3
|
+
|
|
4
|
+
/** Coerce a value that may be a single item or an array into an array. */
|
|
5
|
+
function asArray<T>(v: T | T[] | null | undefined): T[] {
|
|
6
|
+
if (v == null) return []
|
|
7
|
+
return Array.isArray(v) ? v : [v]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse the N4 `/VESSEL` `query-response.data-table` envelope into a single
|
|
12
|
+
* vessel-visit row. A `/VESSEL?VISIT_REF=` query targets exactly one visit, so
|
|
13
|
+
* we return the first row (or null when there are none). Columns are mapped by
|
|
14
|
+
* header name, mirroring parseUnitDataTable.
|
|
15
|
+
*
|
|
16
|
+
* Real columns: Visit, Vessel Name, I/B Vyg, O/B Vyg, Line, Phase, ETA, ETD,
|
|
17
|
+
* ATA, ATD, Begin Receive, DryCutoff. The `Visit` column equals the unit's
|
|
18
|
+
* `visitRefIn`/`visitRefOut` (the join key).
|
|
19
|
+
*/
|
|
20
|
+
export function parseVesselDataTable(body: N4DataTableResponse): N4VesselRow | null {
|
|
21
|
+
const table = body?.['query-response']?.['data-table']
|
|
22
|
+
if (!table) return null
|
|
23
|
+
|
|
24
|
+
const columns = asArray(table.columns?.column).map((c) => String(c))
|
|
25
|
+
const rows = asArray(table.rows?.row ?? undefined)
|
|
26
|
+
if (rows.length === 0) return null
|
|
27
|
+
|
|
28
|
+
const row = rows[0]
|
|
29
|
+
const fields = asArray(row.field)
|
|
30
|
+
const get = (header: string): string | null => {
|
|
31
|
+
const i = columns.indexOf(header)
|
|
32
|
+
if (i < 0 || i >= fields.length) return null
|
|
33
|
+
const v = fields[i]
|
|
34
|
+
return v == null || v === '' ? null : String(v)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const raw: Record<string, string | null> = {}
|
|
38
|
+
columns.forEach((col, i) => {
|
|
39
|
+
raw[col] = fields[i] == null || fields[i] === '' ? null : String(fields[i])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const visitRef = get('Visit')
|
|
43
|
+
if (!visitRef) return null
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
visitRef,
|
|
47
|
+
vesselName: get('Vessel Name'),
|
|
48
|
+
ibVoyage: get('I/B Vyg'),
|
|
49
|
+
obVoyage: get('O/B Vyg'),
|
|
50
|
+
line: get('Line'),
|
|
51
|
+
phase: get('Phase'),
|
|
52
|
+
eta: get('ETA'),
|
|
53
|
+
etd: get('ETD'),
|
|
54
|
+
ata: get('ATA'),
|
|
55
|
+
atd: get('ATD'),
|
|
56
|
+
beginReceive: get('Begin Receive'),
|
|
57
|
+
dryCutoff: get('DryCutoff'),
|
|
58
|
+
raw,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ProxyAgent, type Dispatcher } from 'undici'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build (and memoise) an undici `ProxyAgent` for a terminal's `proxyUrl`, to be
|
|
5
|
+
* passed as the `dispatcher` on the N4 `fetch` calls so they egress through a
|
|
6
|
+
* whitelisted IP. Returns undefined when no proxy is configured (direct).
|
|
7
|
+
*
|
|
8
|
+
* Note: undici proxying is HTTP(S) CONNECT only — `proxyUrl` must be an
|
|
9
|
+
* http(s) forward proxy (e.g. tinyproxy/squid), not a SOCKS proxy.
|
|
10
|
+
*/
|
|
11
|
+
const cache = new Map<string, ProxyAgent>()
|
|
12
|
+
|
|
13
|
+
export function proxyDispatcher(proxyUrl?: string | null): Dispatcher | undefined {
|
|
14
|
+
if (!proxyUrl) return undefined
|
|
15
|
+
let agent = cache.get(proxyUrl)
|
|
16
|
+
if (!agent) {
|
|
17
|
+
agent = new ProxyAgent(proxyUrl)
|
|
18
|
+
cache.set(proxyUrl, agent)
|
|
19
|
+
}
|
|
20
|
+
return agent
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Init type for global fetch including undici's `dispatcher` (not in DOM types). */
|
|
24
|
+
export type FetchInitWithDispatcher = RequestInit & { dispatcher?: Dispatcher }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { TerminalStops } from '../../stops'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalized representation of one N4 "Unit Facility Visit" row returned by
|
|
5
|
+
* the terminal `/unit` endpoint (the `query-response.data-table` envelope).
|
|
6
|
+
* Field names mirror the N4 column headers (mapped by name, not position).
|
|
7
|
+
*/
|
|
8
|
+
export type N4UnitRow = {
|
|
9
|
+
ufvGkey: string
|
|
10
|
+
unitNbr: string
|
|
11
|
+
tState?: string | null
|
|
12
|
+
vState?: string | null
|
|
13
|
+
category?: string | null
|
|
14
|
+
lineOp?: string | null
|
|
15
|
+
typeIso?: string | null
|
|
16
|
+
frghtKind?: string | null
|
|
17
|
+
ibActualVisit?: string | null
|
|
18
|
+
obActualVisit?: string | null
|
|
19
|
+
timeIn?: string | null
|
|
20
|
+
timeOut?: string | null
|
|
21
|
+
impediments?: string[] | null
|
|
22
|
+
cenNumber?: string | null
|
|
23
|
+
vgmWeight?: number | null
|
|
24
|
+
cargoWtKg?: number | null
|
|
25
|
+
tareWt?: number | null
|
|
26
|
+
weightKg?: number | null
|
|
27
|
+
seals?: string[] | null
|
|
28
|
+
dskNumber?: string | null
|
|
29
|
+
loaded?: string | null
|
|
30
|
+
/** Per-mode load/pickup STOP flags (Stop-Vsl / Stop-Road / Stop-Rail). */
|
|
31
|
+
stops: TerminalStops
|
|
32
|
+
/** The full header->value map for rawData persistence. */
|
|
33
|
+
raw: Record<string, string | null>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalized representation of one N4 vessel visit row returned by the `/VESSEL`
|
|
38
|
+
* endpoint. Date fields are raw strings ("yyyy-mm-dd hh:mm"); the adapter parses
|
|
39
|
+
* them via parseN4DateTime. There are two voyages — inbound and outbound.
|
|
40
|
+
*/
|
|
41
|
+
export type N4VesselRow = {
|
|
42
|
+
visitRef: string
|
|
43
|
+
vesselName: string | null
|
|
44
|
+
ibVoyage: string | null
|
|
45
|
+
obVoyage: string | null
|
|
46
|
+
line: string | null
|
|
47
|
+
phase: string | null
|
|
48
|
+
eta: string | null
|
|
49
|
+
etd: string | null
|
|
50
|
+
ata: string | null
|
|
51
|
+
atd: string | null
|
|
52
|
+
beginReceive: string | null
|
|
53
|
+
dryCutoff: string | null
|
|
54
|
+
/** The full header->value map for rawData persistence. */
|
|
55
|
+
raw: Record<string, string | null>
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal timestamp timezone handling.
|
|
3
|
+
*
|
|
4
|
+
* Every terminal we integrate (BCT Gdańsk via N4, GCT and BCT Gdynia) reports
|
|
5
|
+
* event times as a naive wall-clock string — `2026-08-21 11:57`, with no offset.
|
|
6
|
+
* Those are *terminal-local* times, not UTC. Reading them as UTC (the old
|
|
7
|
+
* behaviour: append `Z`) shifted every displayed time by the zone's offset — in
|
|
8
|
+
* summer Europe/Warsaw is CEST (+02:00), so a gate-out of 11:57 rendered as
|
|
9
|
+
* 13:57 in the (Warsaw) UI.
|
|
10
|
+
*
|
|
11
|
+
* All current terminals sit in `Europe/Warsaw`; that is the default. When a
|
|
12
|
+
* terminal outside Poland is onboarded, pass its IANA zone into the parsers
|
|
13
|
+
* instead of relying on this constant.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_TERMINAL_TIMEZONE = 'Europe/Warsaw'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How far ahead of UTC `timeZone` is at the instant `utcMs`, in milliseconds.
|
|
19
|
+
* Uses `Intl` so DST is honoured (CET/CEST for Europe/Warsaw) without any
|
|
20
|
+
* external timezone library.
|
|
21
|
+
*/
|
|
22
|
+
function zoneOffsetMs(utcMs: number, timeZone: string): number {
|
|
23
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
24
|
+
timeZone,
|
|
25
|
+
hour12: false,
|
|
26
|
+
year: 'numeric',
|
|
27
|
+
month: '2-digit',
|
|
28
|
+
day: '2-digit',
|
|
29
|
+
hour: '2-digit',
|
|
30
|
+
minute: '2-digit',
|
|
31
|
+
second: '2-digit',
|
|
32
|
+
}).formatToParts(new Date(utcMs))
|
|
33
|
+
const f: Record<string, number> = {}
|
|
34
|
+
for (const p of parts) if (p.type !== 'literal') f[p.type] = Number(p.value)
|
|
35
|
+
// Some engines render midnight as hour 24 rather than 0.
|
|
36
|
+
const hour = f.hour === 24 ? 0 : f.hour
|
|
37
|
+
const asIfUtc = Date.UTC(f.year, f.month - 1, f.day, hour, f.minute, f.second)
|
|
38
|
+
return asIfUtc - utcMs
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert a naive wall-clock datetime — its components written in `timeZone`
|
|
43
|
+
* with no offset — to the correct UTC instant.
|
|
44
|
+
*
|
|
45
|
+
* e.g. `(2026, 8, 21, 11, 57, 0, 'Europe/Warsaw')` during CEST (+02:00) yields
|
|
46
|
+
* `2026-08-21T09:57:00.000Z`.
|
|
47
|
+
*
|
|
48
|
+
* `month` is 1-based (January = 1). Within a DST transition (the ~1h gap or
|
|
49
|
+
* overlap twice a year) the result may be off by the DST delta — acceptable for
|
|
50
|
+
* terminal event timestamps, which never land inside that window in practice.
|
|
51
|
+
*/
|
|
52
|
+
export function zonedNaiveToUtc(
|
|
53
|
+
year: number,
|
|
54
|
+
month: number,
|
|
55
|
+
day: number,
|
|
56
|
+
hour: number,
|
|
57
|
+
minute: number,
|
|
58
|
+
second: number,
|
|
59
|
+
timeZone: string,
|
|
60
|
+
): Date {
|
|
61
|
+
// First approximation: pretend the wall-clock components are already UTC,
|
|
62
|
+
// then subtract the zone's offset at (approximately) that instant.
|
|
63
|
+
const asUtc = Date.UTC(year, month - 1, day, hour, minute, second)
|
|
64
|
+
return new Date(asUtc - zoneOffsetMs(asUtc, timeZone))
|
|
65
|
+
}
|