5.8 KiB
5.8 KiB
name, description, type
| name | description | type |
|---|---|---|
| TCP Ingestion Project Conventions | Tech stack, architecture patterns, ESLint quirks, and key decisions for the tcp-ingestion service at C:\Users\Administrator\projects\trm\tcp-ingestion | project |
TCP Ingestion Project
Why: TRM platform Teltonika GPS ingestion service — Node 22+ ESM, TypeScript strict, pnpm, vitest, pino, prom-client, ioredis, zod.
Key conventions
- ESM-only: all imports use
.jssuffix on relative imports (Node ESM resolution, even for.tssource files) - No bundler:
tsconly, output todist/, runtime is plainnode dist/main.js - Strict TS:
strict: true,noUncheckedIndexedAccess: truein tsconfig.json (build); tsconfig.test.json (standalone, no extends, for ESLint + typecheck of tests) - pnpm workspace —
pnpm-lock.yamlmust be committed
ESLint flat config quirks (critical)
- ESLint uses
tsconfig.test.json(standalone, not extending tsconfig.json which hasrootDir: src) tsconfig.test.jsonmust NOT extendtsconfig.json— stand-alone config that includes bothsrc/**/*andtest/**/*import/no-restricted-pathsrequireseslint-import-resolver-typescript+settings["import/resolver"]in the ESLint config to resolve.js-suffixed imports to.tsfilesbasePath: __dirname+ relative zone paths ("src/core","src/adapters") is the correct format forno-restricted-pathson Windows- The
joinimport fromnode:pathis needed in eslint.config.js for path construction
Architecture
src/core/— vendor-agnostic shell (types, registry, session, server, publish stub)src/adapters/teltonika/— Teltonika adapter (handshake, frame, CRC, device-authority, codec registry)src/config/load.ts— zod-validated env config, fail-fast on missing required varssrc/observability/logger.ts— pino logger, pretty in dev, JSON in prod/testsrc/main.ts— wires everything, graceful SIGTERM/SIGINT shutdown
CRC-16/IBM
- Polynomial
0xA001(reflected 0x8005), init0x0000, no final XOR - CRC scope: from CodecID byte through N2 inclusive (the body field, DataFieldLength bytes)
- CRC field in frame: 4 bytes, value in lower 2 bytes (bytes 2-3 of the 4-byte field)
- Canonical test vector: Codec 8 example body (54 bytes) →
0xC7CF
IMEI handshake
- Wire format:
[2B length BE][IMEI ASCII]; server responds0x01(accept) or0x00(reject) readImeiHandshakeONLY reads — does NOT write; handshake accept/reject is in the adapter session loop afterDeviceAuthority.check()- Valid IMEI:
/^\d{14,16}$/, max length 32 bytes before rejection
Frame loop
BufferedReader— single pending read at a time, accumulates chunks, no concurrent reads needed- Frame: preamble (4B=0) + DataFieldLength (4B) + body (DataFieldLength B) + CRC field (4B)
- CRC mismatch: log warn, do NOT ACK, keep connection open (device retransmits)
- Unknown codec: log warn, destroy socket, no ACK
- N1≠N2:
FrameDropError(n1_n2_mismatch), destroy socket
Codec parsers (tasks 1.5–1.7)
- Codec 8:
src/adapters/teltonika/codec/data/codec8.ts— 1B IO IDs, 1B counts, no NX, no Generation Type - Codec 8E:
src/adapters/teltonika/codec/data/codec8e.ts— 2B IO IDs, 2B counts, NX variable-length (Buffer, zero-copy subarray) - Codec 16:
src/adapters/teltonika/codec/data/codec16.ts— MIXED: 2B IO IDs, 1B counts, Generation Type 1B, no NX - Shared GPS helpers:
src/adapters/teltonika/codec/data/gps-element.ts - All three handlers registered by default in
createTeltonikaAdapter(no custom registry needed) - Special attributes:
__event(all codecs),__generation_type(codec 16 only) - Codec 16 mixed-widths trap: counts=1B (like C8), IO IDs=2B (like C8E) — easily confused
Fixture suite (task 1.9)
- Fixtures:
test/fixtures/teltonika/codec8/,codec8e/,codec16/ - Format:
.hex= body only (CodecID+N1+records+N2, NO preamble/length/CRC),.expected.json= Position[] output - Sentinels in JSON:
"__bigint:<decimal>"→ bigint,"__buffer_b64:<base64>"→ Buffer (empty:"__buffer_b64:") - Loader:
test/fixtures/_loader.ts—loadFixturesFromDir()for auto-discovery,compareToExpected()for bigint/Buffer-aware equality - Auto-discovery: adding a
.hex+.expected.jsonpair = new test with NO test file edits
Redis publisher (task 1.8)
src/core/publish.ts—createPublisher(redis, config, logger, metrics): Publisher+connectRedis(url, logger, maxAttempts)- Publisher: bounded in-memory queue (PUBLISH_QUEUE_CAPACITY, default 10,000), single worker, XADD with approximate MAXLEN trim
Publisher.publish(position, codec)is non-blocking (enqueue only); throwsPublishOverflowErroron overflowPublisher.drain(timeoutMs)for graceful shutdown- Buffer.toJSON() trap:
JSON.stringifywith replacer callsBuffer.prototype.toJSON()first, converting Buffer to{type:'Buffer',data:[...]}before the replacer sees it. Solution: detectinstanceof Uint8Array(direct calls) AND the{type:'Buffer',data:[]}shape (JSON.stringify path) injsonReplacer. CodecLabeltype ('8'|'8E'|'16') is inpublish.ts;AdapterContext.publishwas updated to(position, codec) => Promise<void>- Codec label injection: framing layer (
index.ts) wrapsctx.publish(pos)into(pos) => ctx.publish(pos, codecLabel)at handler dispatch; codec parsers remain unchanged testcontainersadded as devDependency for Redis integration tests
Test patterns
- Mock sockets are
EventEmitter+vi.fn()methods — NOT realnet.Socketinstances - Use
vi.waitFor()with timeout for async socket writes - Session tests need
{ timeout: 10_000 }due to async socket lifecycle - Codec tests: use
makeTestCtxwith a collectedpositions[]array + mock logger
How to apply: Always check these patterns first before introducing new conventions. The ESLint/TypeScript resolver setup is particularly non-obvious.