fit-file-parser 5.2.1 → 6.1.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.
@@ -0,0 +1,368 @@
1
+ const FIT_EPOCH_MS = 631065600000;
2
+ const FIT_BASE_TYPE_WIDTHS = new Map([
3
+ [0, 1],
4
+ [1, 1],
5
+ [2, 1],
6
+ [3, 2],
7
+ [4, 2],
8
+ [5, 4],
9
+ [6, 4],
10
+ [7, 1],
11
+ [8, 4],
12
+ [9, 8],
13
+ [10, 1],
14
+ [11, 2],
15
+ [12, 4],
16
+ [13, 1],
17
+ [14, 8],
18
+ [15, 8],
19
+ [16, 8],
20
+ ]);
21
+ const FIT_UNSIGNED_SCALAR_BASE_TYPES = new Set([0, 2, 4, 6, 10, 11, 12, 13]);
22
+ const CRC_TABLE = [
23
+ 0,
24
+ 0xCC01,
25
+ 0xD801,
26
+ 0x1400,
27
+ 0xF001,
28
+ 0x3C00,
29
+ 0x2800,
30
+ 0xE401,
31
+ 0xA001,
32
+ 0x6C00,
33
+ 0x7800,
34
+ 0xB401,
35
+ 0x5000,
36
+ 0x9C01,
37
+ 0x8801,
38
+ 0x4400,
39
+ ];
40
+ /** Error thrown when the strict raw-message reader rejects a FIT input. */
41
+ export class FitMessageReaderError extends Error {
42
+ constructor(code) {
43
+ super(code);
44
+ this.name = 'FitMessageReaderError';
45
+ this.code = code;
46
+ }
47
+ }
48
+ function calculateCRC(bytes) {
49
+ let value = 0;
50
+ for (const byte of bytes) {
51
+ value = (value >>> 4) ^ CRC_TABLE[value & 15] ^ CRC_TABLE[byte & 15];
52
+ value = (value >>> 4) ^ CRC_TABLE[value & 15] ^ CRC_TABLE[byte >>> 4];
53
+ }
54
+ return value;
55
+ }
56
+ /** Returns the five-bit FIT base-type identifier, or null for reserved/unknown values. */
57
+ export function getFitBaseTypeId(baseType) {
58
+ if (!Number.isInteger(baseType) || baseType < 0 || baseType > 0xFF) {
59
+ return null;
60
+ }
61
+ if (baseType & 0x60) {
62
+ return null;
63
+ }
64
+ const id = baseType & 0x1F;
65
+ return FIT_BASE_TYPE_WIDTHS.has(id) ? id : null;
66
+ }
67
+ function isValidFieldDefinition(field) {
68
+ const baseTypeId = getFitBaseTypeId(field.baseType);
69
+ const width = baseTypeId === null
70
+ ? undefined
71
+ : FIT_BASE_TYPE_WIDTHS.get(baseTypeId);
72
+ return width !== undefined
73
+ && field.size > 0
74
+ && (baseTypeId === 7 || field.size % width === 0);
75
+ }
76
+ /** Reads an unsigned one-, two-, or four-byte raw FIT field. */
77
+ export function readFitUnsignedField(field, expectedBaseType, size, littleEndian) {
78
+ if (!field) {
79
+ return undefined;
80
+ }
81
+ const expectedType = getFitBaseTypeId(expectedBaseType);
82
+ const actualType = getFitBaseTypeId(field.baseType);
83
+ const compatibleByte = size === 1
84
+ && expectedType !== null
85
+ && actualType !== null
86
+ && [0, 2, 13].includes(expectedType)
87
+ && [0, 2, 13].includes(actualType);
88
+ if (expectedType === null
89
+ || actualType === null
90
+ || !FIT_UNSIGNED_SCALAR_BASE_TYPES.has(expectedType)
91
+ || FIT_BASE_TYPE_WIDTHS.get(expectedType) !== size
92
+ || field.size !== size
93
+ || !(field.bytes instanceof Uint8Array)
94
+ || field.bytes.byteLength !== field.size
95
+ || (actualType !== expectedType && !compatibleByte)) {
96
+ throw new TypeError('FIT field does not match the expected unsigned type');
97
+ }
98
+ const view = new DataView(field.bytes.buffer, field.bytes.byteOffset, field.bytes.byteLength);
99
+ const value = size === 1
100
+ ? view.getUint8(0)
101
+ : size === 2
102
+ ? view.getUint16(0, littleEndian)
103
+ : view.getUint32(0, littleEndian);
104
+ const invalid = [10, 11, 12].includes(expectedType)
105
+ ? 0
106
+ : size === 1
107
+ ? 0xFF
108
+ : size === 2
109
+ ? 0xFFFF
110
+ : 0xFFFFFFFF;
111
+ return value === invalid ? undefined : value;
112
+ }
113
+ /** Reads UTF-8 bytes, removes trailing NUL padding, and preserves interior NUL separators. */
114
+ export function readFitStringField(field) {
115
+ if (!field) {
116
+ return undefined;
117
+ }
118
+ if (getFitBaseTypeId(field.baseType) !== 7
119
+ || field.size <= 0
120
+ || !(field.bytes instanceof Uint8Array)
121
+ || field.bytes.byteLength !== field.size) {
122
+ throw new TypeError('FIT field is not a string');
123
+ }
124
+ const value = new TextDecoder('utf-8', {
125
+ fatal: true,
126
+ ignoreBOM: true,
127
+ }).decode(field.bytes).replace(/\0+$/, '');
128
+ return value || undefined;
129
+ }
130
+ function readTimestamp(field, bytes, littleEndian) {
131
+ if (field.size !== 4 || (field.baseType & 0x1F) !== 6) {
132
+ throw new FitMessageReaderError('invalid_structure');
133
+ }
134
+ const value = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, littleEndian);
135
+ return value === 0xFFFFFFFF ? undefined : value;
136
+ }
137
+ function validateOptions(options) {
138
+ var _a;
139
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
140
+ throw new FitMessageReaderError('invalid_input');
141
+ }
142
+ const maxInputBytes = (_a = options.maxInputBytes) !== null && _a !== void 0 ? _a : Number.POSITIVE_INFINITY;
143
+ if (maxInputBytes !== Number.POSITIVE_INFINITY
144
+ && (!Number.isSafeInteger(maxInputBytes) || maxInputBytes < 0)) {
145
+ throw new FitMessageReaderError('invalid_input');
146
+ }
147
+ if (options.messageNumbers === undefined) {
148
+ return { maxInputBytes };
149
+ }
150
+ if (!Array.isArray(options.messageNumbers)) {
151
+ throw new FitMessageReaderError('invalid_input');
152
+ }
153
+ const selectedMessages = new Set();
154
+ for (const messageNumber of options.messageNumbers) {
155
+ if (!Number.isSafeInteger(messageNumber)
156
+ || messageNumber < 0
157
+ || messageNumber > 0xFFFF) {
158
+ throw new FitMessageReaderError('invalid_input');
159
+ }
160
+ selectedMessages.add(messageNumber);
161
+ }
162
+ return { maxInputBytes, selectedMessages };
163
+ }
164
+ /**
165
+ * Strictly validates a FIT file and retains raw fields for selected messages.
166
+ * This entry point does not load the semantic FIT profile or format field values.
167
+ */
168
+ export function readFitMessages(input, options = {}) {
169
+ var _a;
170
+ const { maxInputBytes, selectedMessages } = validateOptions(options);
171
+ if (!(input instanceof ArrayBuffer) && !(input instanceof Uint8Array)) {
172
+ throw new FitMessageReaderError('invalid_input');
173
+ }
174
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
175
+ if (bytes.length > maxInputBytes) {
176
+ throw new FitMessageReaderError('input_limit');
177
+ }
178
+ if (bytes.length < 14) {
179
+ throw new FitMessageReaderError('invalid_header');
180
+ }
181
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
182
+ const headerSize = bytes[0];
183
+ if (headerSize !== 12 && headerSize !== 14) {
184
+ throw new FitMessageReaderError('invalid_header');
185
+ }
186
+ const dataEnd = headerSize + view.getUint32(4, true);
187
+ const protocolMajorVersion = bytes[1] >>> 4;
188
+ if ((protocolMajorVersion !== 1 && protocolMajorVersion !== 2)
189
+ || bytes[8] !== 0x2E
190
+ || bytes[9] !== 0x46
191
+ || bytes[10] !== 0x49
192
+ || bytes[11] !== 0x54
193
+ || dataEnd + 2 !== bytes.length) {
194
+ throw new FitMessageReaderError('invalid_header');
195
+ }
196
+ if (headerSize === 14
197
+ && view.getUint16(12, true) !== 0
198
+ && calculateCRC(bytes.subarray(0, 12)) !== view.getUint16(12, true)) {
199
+ throw new FitMessageReaderError('invalid_crc');
200
+ }
201
+ if (calculateCRC(bytes.subarray(0, dataEnd)) !== view.getUint16(dataEnd, true)) {
202
+ throw new FitMessageReaderError('invalid_crc');
203
+ }
204
+ const definitions = new Map();
205
+ const messageIndexes = new Map();
206
+ const messages = [];
207
+ const issues = [];
208
+ let cursor = headerSize;
209
+ let lastTimestamp;
210
+ const take = (size) => {
211
+ if (cursor + size > dataEnd) {
212
+ throw new FitMessageReaderError('invalid_structure');
213
+ }
214
+ const value = bytes.subarray(cursor, cursor + size);
215
+ cursor += size;
216
+ return value;
217
+ };
218
+ // `take` owns bounds checking and advances the shared cursor for every record component.
219
+ // eslint-disable-next-line no-unmodified-loop-condition
220
+ while (cursor < dataEnd) {
221
+ const recordHeader = take(1)[0];
222
+ const compressed = (recordHeader & 0x80) !== 0;
223
+ const localMessageNumber = compressed
224
+ ? (recordHeader >> 5) & 3
225
+ : recordHeader & 15;
226
+ if (!compressed && (recordHeader & 0x10) !== 0) {
227
+ throw new FitMessageReaderError('invalid_structure');
228
+ }
229
+ if (!compressed && (recordHeader & 0x40) !== 0) {
230
+ const definitionHeader = take(5);
231
+ if (definitionHeader[0] !== 0 || definitionHeader[1] > 1) {
232
+ throw new FitMessageReaderError('invalid_structure');
233
+ }
234
+ const littleEndian = definitionHeader[1] === 0;
235
+ const globalMessageNumber = littleEndian
236
+ ? definitionHeader[2] | (definitionHeader[3] << 8)
237
+ : (definitionHeader[2] << 8) | definitionHeader[3];
238
+ const selected = selectedMessages === undefined
239
+ || selectedMessages.has(globalMessageNumber);
240
+ const readFields = (count) => {
241
+ const fields = [];
242
+ const fieldNumbers = new Set();
243
+ for (let index = 0; index < count; index++) {
244
+ const raw = take(3);
245
+ const field = {
246
+ fieldNumber: raw[0],
247
+ size: raw[1],
248
+ baseType: raw[2],
249
+ };
250
+ if (fieldNumbers.has(field.fieldNumber)
251
+ || field.size === 0
252
+ || (selected && !isValidFieldDefinition(field))) {
253
+ throw new FitMessageReaderError('invalid_structure');
254
+ }
255
+ fieldNumbers.add(field.fieldNumber);
256
+ fields.push(field);
257
+ }
258
+ return fields;
259
+ };
260
+ const fields = readFields(definitionHeader[4]);
261
+ const developerFields = [];
262
+ if ((recordHeader & 0x20) !== 0) {
263
+ const developerFieldKeys = new Set();
264
+ const count = take(1)[0];
265
+ for (let index = 0; index < count; index++) {
266
+ const raw = take(3);
267
+ const key = `${raw[2]}:${raw[0]}`;
268
+ if (raw[1] === 0 || developerFieldKeys.has(key)) {
269
+ throw new FitMessageReaderError('invalid_structure');
270
+ }
271
+ developerFieldKeys.add(key);
272
+ developerFields.push({
273
+ fieldNumber: raw[0],
274
+ size: raw[1],
275
+ developerDataIndex: raw[2],
276
+ });
277
+ }
278
+ }
279
+ definitions.set(localMessageNumber, {
280
+ globalMessageNumber,
281
+ littleEndian,
282
+ fields,
283
+ developerFields,
284
+ });
285
+ continue;
286
+ }
287
+ if (!compressed && (recordHeader & 0x20) !== 0) {
288
+ throw new FitMessageReaderError('invalid_structure');
289
+ }
290
+ const definition = definitions.get(localMessageNumber);
291
+ if (!definition) {
292
+ throw new FitMessageReaderError('invalid_structure');
293
+ }
294
+ const messageIndex = (_a = messageIndexes.get(definition.globalMessageNumber)) !== null && _a !== void 0 ? _a : 0;
295
+ messageIndexes.set(definition.globalMessageNumber, messageIndex + 1);
296
+ const selected = selectedMessages === undefined
297
+ || selectedMessages.has(definition.globalMessageNumber);
298
+ let timestamp;
299
+ let compressedTimestamp;
300
+ if (compressed) {
301
+ const timestampField = definition.fields[0];
302
+ if (!timestampField
303
+ || timestampField.fieldNumber !== 253
304
+ || timestampField.baseType !== 0x86
305
+ || timestampField.size !== 4
306
+ || lastTimestamp === undefined) {
307
+ throw new FitMessageReaderError('invalid_structure');
308
+ }
309
+ timestamp = Math.floor(lastTimestamp / 32) * 32 + (recordHeader & 31);
310
+ if (timestamp < lastTimestamp) {
311
+ timestamp += 32;
312
+ }
313
+ if (timestamp >= 0xFFFFFFFF) {
314
+ throw new FitMessageReaderError('invalid_structure');
315
+ }
316
+ lastTimestamp = timestamp;
317
+ compressedTimestamp = timestamp;
318
+ }
319
+ const fields = [];
320
+ for (const field of definition.fields) {
321
+ if (compressed && field.fieldNumber === 253) {
322
+ continue;
323
+ }
324
+ const raw = take(field.size);
325
+ if (field.fieldNumber === 253) {
326
+ try {
327
+ timestamp = readTimestamp(field, raw, definition.littleEndian);
328
+ lastTimestamp = timestamp;
329
+ }
330
+ catch (_b) {
331
+ lastTimestamp = undefined;
332
+ issues.push({
333
+ code: 'invalid_timestamp',
334
+ globalMessageNumber: definition.globalMessageNumber,
335
+ messageIndex,
336
+ });
337
+ }
338
+ }
339
+ if (selected) {
340
+ fields.push(Object.assign(Object.assign({}, field), { bytes: raw.slice() }));
341
+ }
342
+ }
343
+ const developerFields = [];
344
+ for (const field of definition.developerFields) {
345
+ const raw = take(field.size);
346
+ if (selected) {
347
+ developerFields.push(Object.assign(Object.assign({}, field), { bytes: raw.slice() }));
348
+ }
349
+ }
350
+ if (selected) {
351
+ messages.push(Object.assign(Object.assign(Object.assign({ globalMessageNumber: definition.globalMessageNumber, messageIndex, littleEndian: definition.littleEndian }, (timestamp === undefined ? {} : { timestamp })), (compressedTimestamp === undefined ? {} : { compressedTimestamp })), { fields,
352
+ developerFields }));
353
+ }
354
+ }
355
+ return {
356
+ protocolVersion: bytes[1],
357
+ profileVersion: view.getUint16(2, true),
358
+ messages,
359
+ issues,
360
+ };
361
+ }
362
+ /** Converts a native FIT timestamp to a JavaScript epoch millisecond value. */
363
+ export function fitTimestampToUnixMilliseconds(timestamp) {
364
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp >= 0xFFFFFFFF) {
365
+ throw new RangeError('FIT timestamp must be an integer between 0 and 4294967294');
366
+ }
367
+ return FIT_EPOCH_MS + timestamp * 1000;
368
+ }
@@ -264,6 +264,7 @@ export function generateFitType() {
264
264
  generateProperty('messages', ts.factory.createTypeReferenceNode('ParsedMessages')),
265
265
  generateArrayProperty('raw_developer_fields', ts.factory.createTypeReferenceNode('ParsedRawDeveloperField')),
266
266
  generateArrayProperty('raw_messages', ts.factory.createTypeReferenceNode('ParsedRawFitMessage')),
267
+ generateArrayProperty('unmapped_messages', ts.factory.createTypeReferenceNode('ParsedRawFitMessage')),
267
268
  ...Object.keys(referenceProperties).map(prop => generateProperty(prop, ts.factory.createTypeReferenceNode(snakeToCamel(referenceProperties[prop].replace('?', ''))), referenceProperties[prop].startsWith('?'))),
268
269
  ...Object.keys(collectionProperties).map(prop => generateArrayProperty(prop, collectionProperties[prop] === 'unknown'
269
270
  ? ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fit-file-parser",
3
3
  "type": "module",
4
- "version": "5.2.1",
4
+ "version": "6.1.1",
5
5
  "private": false,
6
6
  "description": "Parse your .FIT files easily, directly from JS (Garmin, Polar, Suunto)",
7
7
  "author": {
@@ -37,13 +37,42 @@
37
37
  "types": "./dist/fit-parser.d.ts",
38
38
  "import": "./dist/fit-parser.js",
39
39
  "require": "./dist/cjs/fit-parser.js"
40
+ },
41
+ "./encoder": {
42
+ "types": "./dist/fit-encoder.d.ts",
43
+ "import": "./dist/fit-encoder.js",
44
+ "require": "./dist/cjs/fit-encoder.js"
45
+ },
46
+ "./profile": {
47
+ "types": "./dist/profile-lookup.d.ts",
48
+ "import": "./dist/profile-lookup.js",
49
+ "require": "./dist/cjs/profile-lookup.js"
50
+ },
51
+ "./raw": {
52
+ "types": "./dist/raw-message-reader.d.ts",
53
+ "import": "./dist/raw-message-reader.js",
54
+ "require": "./dist/cjs/raw-message-reader.js"
40
55
  }
41
56
  },
42
57
  "main": "dist/cjs/fit-parser.js",
58
+ "typesVersions": {
59
+ "*": {
60
+ "encoder": [
61
+ "dist/fit-encoder.d.ts"
62
+ ],
63
+ "profile": [
64
+ "dist/profile-lookup.d.ts"
65
+ ],
66
+ "raw": [
67
+ "dist/raw-message-reader.d.ts"
68
+ ]
69
+ }
70
+ },
43
71
  "files": [
44
72
  "CHANGELOG.md",
45
73
  "CONTRIBUTORS.md",
46
74
  "LICENSE",
75
+ "PROFILE.md",
47
76
  "README.md",
48
77
  "dist/"
49
78
  ],
@@ -54,13 +83,13 @@
54
83
  }
55
84
  ],
56
85
  "scripts": {
57
- "dev": "tsx --watch --watch-path=src/type_generator.ts --watch-path=src/fit.ts --watch-path=src/garmin_profile.generated.ts codegen/codegen.ts",
58
- "codegen:profile": "tsx codegen/garmin-profile.ts",
59
- "codegen": "npm run codegen:profile && tsx codegen/codegen.ts",
60
- "codegen:check": "tsx codegen/garmin-profile.ts --check && tsx codegen/codegen.ts --check",
61
- "profile:audit": "npm run codegen:check && tsx scripts/profile-audit.ts",
86
+ "dev": "tsx --watch --watch-path=src/type_generator.ts --watch-path=src/fit.ts --watch-path=src/profile.ts codegen/codegen.ts",
87
+ "codegen": "tsx codegen/codegen.ts",
88
+ "codegen:check": "tsx codegen/codegen.ts --check",
89
+ "profile:check": "npm run codegen:check && tsx scripts/profile-check.ts",
90
+ "compatibility:check": "npm run build && tsx scripts/compatibility-check.ts",
62
91
  "corpus:check": "tsx scripts/corpus-check.ts",
63
- "check": "npm run profile:audit && npm run lint && npm run type-check && npm test -- --run && npm run build",
92
+ "check": "npm run profile:check && npm run lint && npm run type-check && npm test -- --run && npm run build",
64
93
  "example-output": "node examples/parse.js examples/example.fit > examples/output-example.json",
65
94
  "example-output-tank": "node examples/parse.js examples/example-diving.fit > examples/output-diving.json",
66
95
  "example-output-summary-first": "node examples/parse.js examples/triathlon_summary_first.fit > examples/output_summary_first.json",
@@ -71,7 +100,8 @@
71
100
  "lint": "eslint .",
72
101
  "fmt": "eslint --fix .",
73
102
  "type-check": "tsc --noEmit",
74
- "build": "tsc --noCheck && tsc --project tsconfig.cjs.json --noCheck && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
103
+ "clean": "tsx scripts/clean-dist.ts",
104
+ "build": "npm run clean && tsc --noCheck && tsc --project tsconfig.cjs.json --noCheck && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
75
105
  "prepack": "npm run build"
76
106
  },
77
107
  "test": "vitest",
@@ -80,7 +110,6 @@
80
110
  },
81
111
  "devDependencies": {
82
112
  "@antfu/eslint-config": "^6.2.0",
83
- "@garmin/fitsdk": "21.208.0",
84
113
  "@types/node": "^24.10.1",
85
114
  "@vitest/coverage-v8": "^4.0.13",
86
115
  "eslint": "^9.10.0",
@@ -1,9 +0,0 @@
1
- import type { Message } from './fit.js';
2
- export declare const GARMIN_PROFILE_VERSION: {
3
- readonly major: 21;
4
- readonly minor: 208;
5
- readonly patch: 0;
6
- readonly type: "Release";
7
- };
8
- export declare const GARMIN_MESSAGES: Record<number, Message>;
9
- export declare const GARMIN_TYPES: Record<string, Record<number, string | number>>;
@@ -1,9 +0,0 @@
1
- import type { Message } from './fit.js';
2
- export declare const GARMIN_PROFILE_VERSION: {
3
- readonly major: 21;
4
- readonly minor: 208;
5
- readonly patch: 0;
6
- readonly type: "Release";
7
- };
8
- export declare const GARMIN_MESSAGES: Record<number, Message>;
9
- export declare const GARMIN_TYPES: Record<string, Record<number, string | number>>;