@typespec/http-server-js 0.58.0-alpha.12-dev.0 → 0.58.0-alpha.12-dev.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.
Files changed (61) hide show
  1. package/dist/generated-defs/helpers/datetime.d.ts +4 -0
  2. package/dist/generated-defs/helpers/datetime.d.ts.map +1 -0
  3. package/dist/generated-defs/helpers/datetime.js +256 -0
  4. package/dist/generated-defs/helpers/datetime.js.map +1 -0
  5. package/dist/generated-defs/helpers/index.d.ts.map +1 -1
  6. package/dist/generated-defs/helpers/index.js +1 -0
  7. package/dist/generated-defs/helpers/index.js.map +1 -1
  8. package/dist/src/common/declaration.js +1 -1
  9. package/dist/src/common/declaration.js.map +1 -1
  10. package/dist/src/common/reference.js +1 -1
  11. package/dist/src/common/reference.js.map +1 -1
  12. package/dist/src/common/scalar.d.ts +175 -22
  13. package/dist/src/common/scalar.d.ts.map +1 -1
  14. package/dist/src/common/scalar.js +420 -93
  15. package/dist/src/common/scalar.js.map +1 -1
  16. package/dist/src/common/serialization/index.d.ts +2 -2
  17. package/dist/src/common/serialization/index.d.ts.map +1 -1
  18. package/dist/src/common/serialization/index.js +9 -3
  19. package/dist/src/common/serialization/index.js.map +1 -1
  20. package/dist/src/common/serialization/json.d.ts +2 -2
  21. package/dist/src/common/serialization/json.d.ts.map +1 -1
  22. package/dist/src/common/serialization/json.js +144 -42
  23. package/dist/src/common/serialization/json.js.map +1 -1
  24. package/dist/src/helpers/datetime.d.ts +92 -0
  25. package/dist/src/helpers/datetime.d.ts.map +1 -0
  26. package/dist/src/helpers/datetime.js +151 -0
  27. package/dist/src/helpers/datetime.js.map +1 -0
  28. package/dist/src/http/server/index.d.ts.map +1 -1
  29. package/dist/src/http/server/index.js +17 -12
  30. package/dist/src/http/server/index.js.map +1 -1
  31. package/dist/src/http/server/multipart.js +1 -1
  32. package/dist/src/http/server/multipart.js.map +1 -1
  33. package/dist/src/lib.d.ts +10 -1
  34. package/dist/src/lib.d.ts.map +1 -1
  35. package/dist/src/lib.js +6 -0
  36. package/dist/src/lib.js.map +1 -1
  37. package/dist/src/util/case.d.ts +9 -0
  38. package/dist/src/util/case.d.ts.map +1 -1
  39. package/dist/src/util/case.js +18 -0
  40. package/dist/src/util/case.js.map +1 -1
  41. package/dist/src/util/differentiate.d.ts +4 -4
  42. package/dist/src/util/differentiate.d.ts.map +1 -1
  43. package/dist/src/util/differentiate.js +10 -10
  44. package/dist/src/util/differentiate.js.map +1 -1
  45. package/generated-defs/helpers/datetime.ts +263 -0
  46. package/generated-defs/helpers/index.ts +1 -0
  47. package/package.json +7 -7
  48. package/src/common/declaration.ts +1 -1
  49. package/src/common/reference.ts +1 -1
  50. package/src/common/scalar.ts +709 -103
  51. package/src/common/serialization/index.ts +11 -4
  52. package/src/common/serialization/json.ts +174 -52
  53. package/src/helpers/datetime.ts +235 -0
  54. package/src/http/server/index.ts +29 -15
  55. package/src/http/server/multipart.ts +1 -1
  56. package/src/lib.ts +6 -0
  57. package/src/util/case.ts +19 -0
  58. package/src/util/differentiate.ts +15 -8
  59. package/temp/tsconfig.tsbuildinfo +1 -1
  60. package/test/datetime.test.ts +226 -0
  61. package/test/scalar.test.ts +345 -0
@@ -55,9 +55,15 @@ export function emitSerialization(ctx: JsContext): void {
55
55
  const serializations = _SERIALIZATIONS_MAP.get(type)!;
56
56
 
57
57
  const requiredSerializations = new Set<SerializationContentType>(
58
- [...serializations].filter((serialization) =>
59
- isSerializationRequired(ctx, type, serialization),
60
- ),
58
+ [...serializations].filter((serialization) => {
59
+ const isSynthetic = ctx.syntheticNames.has(type) || !type.namespace;
60
+
61
+ const module = isSynthetic
62
+ ? ctx.syntheticModule
63
+ : createOrGetModuleForNamespace(ctx, type.namespace!);
64
+
65
+ return isSerializationRequired(ctx, module, type, serialization);
66
+ }),
61
67
  );
62
68
 
63
69
  if (requiredSerializations.size > 0) {
@@ -68,12 +74,13 @@ export function emitSerialization(ctx: JsContext): void {
68
74
 
69
75
  export function isSerializationRequired(
70
76
  ctx: JsContext,
77
+ module: Module,
71
78
  type: Type,
72
79
  serialization: SerializationContentType,
73
80
  ): boolean {
74
81
  switch (serialization) {
75
82
  case "application/json": {
76
- return requiresJsonSerialization(ctx, type);
83
+ return requiresJsonSerialization(ctx, module, type);
77
84
  }
78
85
  default:
79
86
  throw new Error(`Unreachable: serialization content type ${serialization satisfies never}`);
@@ -3,10 +3,12 @@
3
3
 
4
4
  import {
5
5
  BooleanLiteral,
6
+ DiagnosticTarget,
6
7
  IntrinsicType,
7
8
  ModelProperty,
8
9
  NoTarget,
9
10
  NumericLiteral,
11
+ Scalar,
10
12
  StringLiteral,
11
13
  Type,
12
14
  compilerAssert,
@@ -17,12 +19,15 @@ import {
17
19
  } from "@typespec/compiler";
18
20
  import { getHeaderFieldOptions, getPathParamOptions, getQueryParamOptions } from "@typespec/http";
19
21
  import { JsContext, Module } from "../../ctx.js";
20
- import { parseCase } from "../../util/case.js";
22
+ import { reportDiagnostic } from "../../lib.js";
23
+ import { access, parseCase } from "../../util/case.js";
21
24
  import { differentiateUnion, writeCodeTree } from "../../util/differentiate.js";
22
25
  import { UnimplementedError } from "../../util/error.js";
23
26
  import { indent } from "../../util/iter.js";
27
+ import { keywordSafe } from "../../util/keywords.js";
28
+ import { getFullyQualifiedTypeName } from "../../util/name.js";
24
29
  import { emitTypeReference, escapeUnsafeChars } from "../reference.js";
25
- import { getJsScalar } from "../scalar.js";
30
+ import { Encoder, JS_SCALAR_UNKNOWN, JsScalar, getJsScalar } from "../scalar.js";
26
31
  import { SerializableType, SerializationContext, requireSerialization } from "./index.js";
27
32
 
28
33
  /**
@@ -30,7 +35,12 @@ import { SerializableType, SerializationContext, requireSerialization } from "./
30
35
  */
31
36
  const _REQUIRES_JSON_SERIALIZATION = new WeakMap<SerializableType | ModelProperty, boolean>();
32
37
 
33
- export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean {
38
+ export function requiresJsonSerialization(
39
+ ctx: JsContext,
40
+ module: Module,
41
+ type: Type,
42
+ diagnosticTarget: DiagnosticTarget | typeof NoTarget = NoTarget,
43
+ ): boolean {
34
44
  if (!isSerializable(type)) return false;
35
45
 
36
46
  if (_REQUIRES_JSON_SERIALIZATION.has(type)) {
@@ -48,28 +58,31 @@ export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean {
48
58
  case "Model": {
49
59
  if (isArrayModelType(ctx.program, type)) {
50
60
  const argumentType = type.indexer.value;
51
- requiresSerialization = requiresJsonSerialization(ctx, argumentType);
61
+ requiresSerialization = requiresJsonSerialization(ctx, module, argumentType);
52
62
  break;
53
63
  }
54
64
 
55
65
  requiresSerialization = [...type.properties.values()].some((property) =>
56
- propertyRequiresJsonSerialization(ctx, property),
66
+ propertyRequiresJsonSerialization(ctx, module, property),
57
67
  );
58
68
  break;
59
69
  }
60
70
  case "Scalar": {
61
- const scalar = getJsScalar(ctx.program, type, type);
62
- requiresSerialization = scalar === "Uint8Array" || getEncode(ctx.program, type) !== undefined;
71
+ const scalar = getJsScalar(ctx, module, type, diagnosticTarget);
72
+ requiresSerialization =
73
+ !scalar.isJsonCompatible ||
74
+ getEncode(ctx.program, type) !== undefined ||
75
+ scalar.getDefaultMimeEncoding("application/json") !== undefined;
63
76
  break;
64
77
  }
65
78
  case "Union": {
66
79
  requiresSerialization = [...type.variants.values()].some((variant) =>
67
- requiresJsonSerialization(ctx, variant),
80
+ requiresJsonSerialization(ctx, module, variant),
68
81
  );
69
82
  break;
70
83
  }
71
84
  case "ModelProperty":
72
- requiresSerialization = requiresJsonSerialization(ctx, type.type);
85
+ requiresSerialization = requiresJsonSerialization(ctx, module, type.type);
73
86
  break;
74
87
  }
75
88
 
@@ -78,12 +91,17 @@ export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean {
78
91
  return requiresSerialization;
79
92
  }
80
93
 
81
- function propertyRequiresJsonSerialization(ctx: JsContext, property: ModelProperty): boolean {
94
+ function propertyRequiresJsonSerialization(
95
+ ctx: JsContext,
96
+ module: Module,
97
+ property: ModelProperty,
98
+ ): boolean {
82
99
  return !!(
83
100
  isHttpMetadata(ctx, property) ||
84
101
  getEncode(ctx.program, property) ||
85
102
  resolveEncodedName(ctx.program, property, "application/json") !== property.name ||
86
- (isSerializable(property.type) && requiresJsonSerialization(ctx, property.type))
103
+ (isSerializable(property.type) &&
104
+ requiresJsonSerialization(ctx, module, property.type, property))
87
105
  );
88
106
  }
89
107
 
@@ -132,12 +150,40 @@ function* emitToJson(
132
150
  const encodedName =
133
151
  resolveEncodedName(ctx.program, property, "application/json") ?? property.name;
134
152
 
135
- const expr = transposeExpressionToJson(
136
- ctx,
137
- property.type,
138
- `input.${property.name}`,
139
- module,
140
- );
153
+ const propertyName = keywordSafe(parseCase(property.name).camelCase);
154
+
155
+ let expr: string = access("input", propertyName);
156
+
157
+ const encoding = getEncode(ctx.program, property);
158
+
159
+ if (property.type.kind === "Scalar" && encoding) {
160
+ const scalar = getJsScalar(ctx, module, property.type, property.type);
161
+ const scalarEncoder = scalar.getEncoding(encoding.encoding ?? "default", encoding.type);
162
+
163
+ if (scalarEncoder) {
164
+ expr = transposeExpressionToJson(
165
+ ctx,
166
+ // Assertion: scalarEncoder.target.scalar is defined because we resolved an encoder.
167
+ scalarEncoder.target.scalar as Scalar,
168
+ scalarEncoder.encode(expr),
169
+ module,
170
+ );
171
+ } else {
172
+ reportDiagnostic(ctx.program, {
173
+ code: "unknown-encoding",
174
+ target: NoTarget,
175
+ format: {
176
+ encoding: encoding.encoding ?? "<default>",
177
+ type: getFullyQualifiedTypeName(property.type),
178
+ target: getFullyQualifiedTypeName(encoding.type),
179
+ },
180
+ });
181
+
182
+ // We treat this as unknown from here on out. The encoding was not deciphered.
183
+ }
184
+ } else {
185
+ expr = transposeExpressionToJson(ctx, property.type, expr, module);
186
+ }
141
187
 
142
188
  yield ` ${encodedName}: ${expr},`;
143
189
  }
@@ -151,12 +197,12 @@ function* emitToJson(
151
197
  return;
152
198
  }
153
199
  case "Union": {
154
- const codeTree = differentiateUnion(ctx, type);
200
+ const codeTree = differentiateUnion(ctx, module, type);
155
201
 
156
202
  yield* writeCodeTree(ctx, codeTree, {
157
203
  subject: "input",
158
204
  referenceModelProperty(p) {
159
- return "input." + parseCase(p.name).camelCase;
205
+ return access("input", parseCase(p.name).camelCase);
160
206
  },
161
207
  renderResult(type) {
162
208
  return [`return ${transposeExpressionToJson(ctx, type, "input", module)};`];
@@ -179,15 +225,15 @@ function transposeExpressionToJson(
179
225
  if (isArrayModelType(ctx.program, type)) {
180
226
  const argumentType = type.indexer.value;
181
227
 
182
- if (requiresJsonSerialization(ctx, argumentType)) {
183
- return `${expr}?.map((item) => ${transposeExpressionToJson(ctx, argumentType, "item", module)})`;
228
+ if (requiresJsonSerialization(ctx, module, argumentType)) {
229
+ return `(${expr})?.map((item) => ${transposeExpressionToJson(ctx, argumentType, "item", module)})`;
184
230
  } else {
185
231
  return expr;
186
232
  }
187
233
  } else if (isRecordModelType(ctx.program, type)) {
188
234
  const argumentType = type.indexer.value;
189
235
 
190
- if (requiresJsonSerialization(ctx, argumentType)) {
236
+ if (requiresJsonSerialization(ctx, module, argumentType)) {
191
237
  return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [String(key), ${transposeExpressionToJson(
192
238
  ctx,
193
239
  argumentType,
@@ -197,7 +243,7 @@ function transposeExpressionToJson(
197
243
  } else {
198
244
  return expr;
199
245
  }
200
- } else if (!requiresJsonSerialization(ctx, type)) {
246
+ } else if (!requiresJsonSerialization(ctx, module, type)) {
201
247
  return expr;
202
248
  } else {
203
249
  requireSerialization(ctx, type, "application/json");
@@ -207,18 +253,20 @@ function transposeExpressionToJson(
207
253
  }
208
254
  }
209
255
  case "Scalar":
210
- const scalar = getJsScalar(ctx.program, type, type);
256
+ const scalar = getJsScalar(ctx, module, type, NoTarget);
211
257
 
212
- switch (scalar) {
213
- case "Uint8Array":
214
- // Coerce to Buffer if we aren't given a buffer. This avoids having to do unholy things to
215
- // convert through an intermediate and use globalThis.btoa. v8 does not support Uint8Array.toBase64
216
- return `((${expr} instanceof Buffer) ? ${expr} : Buffer.from(${expr})).toString('base64')`;
217
- default:
218
- return expr;
258
+ const encoder: Encoder = getScalarEncoder(ctx, type, scalar);
259
+
260
+ const encoded = encoder.encode(expr);
261
+
262
+ if (encoder.target.isJsonCompatible || !encoder.target.scalar) {
263
+ return encoded;
264
+ } else {
265
+ // Assertion: encoder.target.scalar is a scalar because "unknown" is JSON compatible.
266
+ return transposeExpressionToJson(ctx, encoder.target.scalar as Scalar, encoded, module);
219
267
  }
220
268
  case "Union":
221
- if (!requiresJsonSerialization(ctx, type)) {
269
+ if (!requiresJsonSerialization(ctx, module, type)) {
222
270
  return expr;
223
271
  } else {
224
272
  requireSerialization(ctx, type, "application/json");
@@ -269,6 +317,48 @@ function transposeExpressionToJson(
269
317
  }
270
318
  }
271
319
 
320
+ function getScalarEncoder(ctx: SerializationContext, type: Scalar, scalar: JsScalar) {
321
+ const encoding = getEncode(ctx.program, type);
322
+
323
+ let encoder: Encoder;
324
+
325
+ if (encoding) {
326
+ const encodingName = encoding.encoding ?? "default";
327
+ const scalarEncoder = scalar.getEncoding(encodingName, encoding.type);
328
+
329
+ // TODO - we should detect this before realizing models and use a transform to represent
330
+ // the defective scalar as the encoding target type.
331
+ // See: https://github.com/microsoft/typespec/issues/6376
332
+ if (!scalarEncoder) {
333
+ reportDiagnostic(ctx.program, {
334
+ code: "unknown-encoding",
335
+ target: NoTarget,
336
+ format: {
337
+ encoding: encoding.encoding ?? "<default>",
338
+ type: getFullyQualifiedTypeName(type),
339
+ target: getFullyQualifiedTypeName(encoding.type),
340
+ },
341
+ });
342
+
343
+ encoder = {
344
+ target: JS_SCALAR_UNKNOWN,
345
+ encode: (expr) => expr,
346
+ decode: (expr) => expr,
347
+ };
348
+ } else {
349
+ encoder = scalarEncoder;
350
+ }
351
+ } else {
352
+ // No encoding specified, use the default content type encoding for json
353
+ encoder = scalar.getDefaultMimeEncoding("application/json") ?? {
354
+ target: JS_SCALAR_UNKNOWN,
355
+ encode: (expr) => expr,
356
+ decode: (expr) => expr,
357
+ };
358
+ }
359
+ return encoder;
360
+ }
361
+
272
362
  function literalToExpr(type: StringLiteral | BooleanLiteral | NumericLiteral): string {
273
363
  switch (type.kind) {
274
364
  case "String":
@@ -292,14 +382,42 @@ function* emitFromJson(
292
382
  const encodedName =
293
383
  resolveEncodedName(ctx.program, property, "application/json") ?? property.name;
294
384
 
295
- const expr = transposeExpressionFromJson(
296
- ctx,
297
- property.type,
298
- `input["${encodedName}"]`,
299
- module,
300
- );
385
+ let expr = access("input", encodedName);
386
+
387
+ const encoding = getEncode(ctx.program, property);
388
+
389
+ if (property.type.kind === "Scalar" && encoding) {
390
+ const scalar = getJsScalar(ctx, module, property.type, property.type);
391
+ const scalarEncoder = scalar.getEncoding(encoding.encoding ?? "default", encoding.type);
392
+
393
+ if (scalarEncoder) {
394
+ expr = transposeExpressionFromJson(
395
+ ctx,
396
+ // Assertion: scalarEncoder.target.scalar is defined because we resolved an encoder.
397
+ scalarEncoder.target.scalar as Scalar,
398
+ scalarEncoder.decode(expr),
399
+ module,
400
+ );
401
+ } else {
402
+ reportDiagnostic(ctx.program, {
403
+ code: "unknown-encoding",
404
+ target: NoTarget,
405
+ format: {
406
+ encoding: encoding.encoding ?? "<default>",
407
+ type: getFullyQualifiedTypeName(property.type),
408
+ target: getFullyQualifiedTypeName(encoding.type),
409
+ },
410
+ });
411
+
412
+ // We treat this as unknown from here on out. The encoding was not deciphered.
413
+ }
414
+ } else {
415
+ expr = transposeExpressionFromJson(ctx, property.type, expr, module);
416
+ }
417
+
418
+ const propertyName = keywordSafe(parseCase(property.name).camelCase);
301
419
 
302
- yield ` ${property.name}: ${expr},`;
420
+ yield ` ${propertyName}: ${expr},`;
303
421
  }
304
422
 
305
423
  yield "};";
@@ -311,13 +429,13 @@ function* emitFromJson(
311
429
  return;
312
430
  }
313
431
  case "Union": {
314
- const codeTree = differentiateUnion(ctx, type);
432
+ const codeTree = differentiateUnion(ctx, module, type);
315
433
 
316
434
  yield* writeCodeTree(ctx, codeTree, {
317
435
  subject: "input",
318
436
  referenceModelProperty(p) {
319
437
  const jsonName = resolveEncodedName(ctx.program, p, "application/json") ?? p.name;
320
- return "input[" + JSON.stringify(jsonName) + "]";
438
+ return access("input", jsonName);
321
439
  },
322
440
  renderResult(type) {
323
441
  return [`return ${transposeExpressionFromJson(ctx, type, "input", module)};`];
@@ -340,15 +458,15 @@ function transposeExpressionFromJson(
340
458
  if (isArrayModelType(ctx.program, type)) {
341
459
  const argumentType = type.indexer.value;
342
460
 
343
- if (requiresJsonSerialization(ctx, argumentType)) {
344
- return `${expr}?.map((item: any) => ${transposeExpressionFromJson(ctx, argumentType, "item", module)})`;
461
+ if (requiresJsonSerialization(ctx, module, argumentType)) {
462
+ return `(${expr})?.map((item: any) => ${transposeExpressionFromJson(ctx, argumentType, "item", module)})`;
345
463
  } else {
346
464
  return expr;
347
465
  }
348
466
  } else if (isRecordModelType(ctx.program, type)) {
349
467
  const argumentType = type.indexer.value;
350
468
 
351
- if (requiresJsonSerialization(ctx, argumentType)) {
469
+ if (requiresJsonSerialization(ctx, module, argumentType)) {
352
470
  return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [key, ${transposeExpressionFromJson(
353
471
  ctx,
354
472
  argumentType,
@@ -358,7 +476,7 @@ function transposeExpressionFromJson(
358
476
  } else {
359
477
  return expr;
360
478
  }
361
- } else if (!requiresJsonSerialization(ctx, type)) {
479
+ } else if (!requiresJsonSerialization(ctx, module, type)) {
362
480
  return `${expr} as ${emitTypeReference(ctx, type, NoTarget, module)}`;
363
481
  } else {
364
482
  requireSerialization(ctx, type, "application/json");
@@ -368,16 +486,20 @@ function transposeExpressionFromJson(
368
486
  }
369
487
  }
370
488
  case "Scalar":
371
- const scalar = getJsScalar(ctx.program, type, type);
489
+ const scalar = getJsScalar(ctx, module, type, type);
372
490
 
373
- switch (scalar) {
374
- case "Uint8Array":
375
- return `Buffer.from(${expr}, 'base64')`;
376
- default:
377
- return expr;
491
+ const encoder = getScalarEncoder(ctx, type, scalar);
492
+
493
+ const decoded = encoder.decode(expr);
494
+
495
+ if (encoder.target.isJsonCompatible || !encoder.target.scalar) {
496
+ return decoded;
497
+ } else {
498
+ // Assertion: encoder.target.scalar is a scalar because "unknown" is JSON compatible.
499
+ return transposeExpressionFromJson(ctx, encoder.target.scalar as Scalar, decoded, module);
378
500
  }
379
501
  case "Union":
380
- if (!requiresJsonSerialization(ctx, type)) {
502
+ if (!requiresJsonSerialization(ctx, module, type)) {
381
503
  return expr;
382
504
  } else {
383
505
  requireSerialization(ctx, type, "application/json");
@@ -0,0 +1,235 @@
1
+ // Copyright (c) Microsoft Corporation
2
+ // Licensed under the MIT license.
3
+
4
+ // #region Duration
5
+
6
+ /**
7
+ * Regular expression for matching ISO8601 duration strings.
8
+ *
9
+ * Yields:
10
+ * - 0: the full match
11
+ * - 1: the sign (optional)
12
+ * - 2: years (optional)
13
+ * - 3: months (optional)
14
+ * - 4: weeks (optional)
15
+ * - 5: days (optional)
16
+ * - 6: hours (optional)
17
+ * - 7: minutes (optional)
18
+ * - 8: seconds (optional)
19
+ */
20
+ const ISO8601_DURATION_REGEX =
21
+ /^(-)?P(?:((?:\d*[.,])?\d+)Y)?(?:((?:\d*[.,])?\d+)M)?(?:((?:\d*[.,])?\d+)W)?(?:((?:\d*[.,])?\d+)D)?(?:T(?:((?:\d*[.,])?\d+)H)?(?:((?:\d*[.,])?\d+)M)?(?:((?:\d*[.,])?\d+)S)?)?$/;
22
+
23
+ /**
24
+ * A duration of time, measured in years, months, weeks, days, hours, minutes, and seconds.
25
+ *
26
+ * The values may be fractional and are not normalized (e.g. 36 hours is not the same duration as 1 day and 12 hours
27
+ * when accounting for Daylight Saving Time changes or leap seconds).
28
+ *
29
+ * @see https://en.wikipedia.org/wiki/ISO_8601#Durations
30
+ */
31
+ export interface Duration {
32
+ /**
33
+ * "+" if the duration is positive, "-" if the duration is negative.
34
+ */
35
+ sign: "+" | "-";
36
+ /**
37
+ * The number of years in the duration.
38
+ */
39
+ years: number;
40
+ /**
41
+ * The number of months in the duration.
42
+ */
43
+ months: number;
44
+ /**
45
+ * The number of weeks in the duration.
46
+ */
47
+ weeks: number;
48
+ /**
49
+ * The number of days in the duration.
50
+ */
51
+ days: number;
52
+ /**
53
+ * The number of hours in the duration.
54
+ */
55
+ hours: number;
56
+ /**
57
+ * The number of minutes in the duration.
58
+ */
59
+ minutes: number;
60
+ /**
61
+ * The number of seconds in the duration.
62
+ */
63
+ seconds: number;
64
+ }
65
+
66
+ export const Duration = Object.freeze({
67
+ /**
68
+ * Parses an ISO8601 duration string into an object.
69
+ *
70
+ * @see https://en.wikipedia.org/wiki/ISO_8601#Durations
71
+ *
72
+ * @param duration - the duration string to parse
73
+ * @returns an object containing the parsed duration
74
+ */
75
+ parseISO8601(duration: string, maxLength: number = 100): Duration {
76
+ duration = duration.trim();
77
+ if (duration.length > maxLength)
78
+ throw new Error(`ISO8601 duration string is too long: ${duration}`);
79
+
80
+ const match = duration.match(ISO8601_DURATION_REGEX);
81
+
82
+ if (!match) throw new Error(`Invalid ISO8601 duration: ${duration}`);
83
+
84
+ return {
85
+ sign: match[1] === undefined ? "+" : (match[1] as Duration["sign"]),
86
+ years: parseFloatNormal(match[2]),
87
+ months: parseFloatNormal(match[3]),
88
+ weeks: parseFloatNormal(match[4]),
89
+ days: parseFloatNormal(match[5]),
90
+ hours: parseFloatNormal(match[6]),
91
+ minutes: parseFloatNormal(match[7]),
92
+ seconds: parseFloatNormal(match[8]),
93
+ };
94
+
95
+ function parseFloatNormal(match: string | undefined): number {
96
+ if (match === undefined) return 0;
97
+
98
+ const normalized = match.replace(",", ".");
99
+
100
+ const parsed = parseFloat(normalized);
101
+
102
+ if (isNaN(parsed))
103
+ throw new Error(`Unreachable: Invalid number in ISO8601 duration string: ${match}`);
104
+
105
+ return parsed;
106
+ }
107
+ },
108
+ /**
109
+ * Writes a Duration to an ISO8601 duration string.
110
+ *
111
+ * @see https://en.wikipedia.org/wiki/ISO_8601#Durations
112
+ *
113
+ * @param duration - the duration to write to a string
114
+ * @returns a string in ISO8601 duration format
115
+ */
116
+ toISO8601(duration: Duration): string {
117
+ const sign = duration.sign === "+" ? "" : "-";
118
+
119
+ const years =
120
+ duration.years !== 0 && !isNaN(Number(duration.years)) ? `${duration.years}Y` : "";
121
+ const months =
122
+ duration.months !== 0 && !isNaN(Number(duration.months)) ? `${duration.months}M` : "";
123
+ const weeks =
124
+ duration.weeks !== 0 && !isNaN(Number(duration.weeks)) ? `${duration.weeks}W` : "";
125
+ const days = duration.days !== 0 && !isNaN(Number(duration.days)) ? `${duration.days}D` : "";
126
+
127
+ let time = "";
128
+
129
+ const _hours = duration.hours !== 0 && !isNaN(Number(duration.hours));
130
+ const _minutes = duration.minutes !== 0 && !isNaN(Number(duration.minutes));
131
+ const _seconds = duration.seconds !== 0 && !isNaN(Number(duration.seconds));
132
+
133
+ if (_hours || _minutes || _seconds) {
134
+ const hours = _hours ? `${duration.hours}H` : "";
135
+ const minutes = _minutes ? `${duration.minutes}M` : "";
136
+ const seconds = _seconds ? `${duration.seconds}S` : "";
137
+
138
+ time = `T${hours}${minutes}${seconds}`;
139
+ }
140
+
141
+ return `${sign}P${years}${months}${weeks}${days}${time}`;
142
+ },
143
+
144
+ /**
145
+ * Gets the total number of seconds in a duration.
146
+ *
147
+ * This method will throw an Error if the duration contains any years, months, weeks, or days, as those require a reference
148
+ * point to calculate the total number of seconds.
149
+ *
150
+ * WARNING: If the total number of seconds is larger than the maximum safe integer in JavaScript, this method will
151
+ * lose precision. @see Duration.totalSecondsBigInt for a BigInt alternative.
152
+ *
153
+ * @param duration - the duration to calculate the total number of seconds for
154
+ * @returns the total number of seconds in the duration
155
+ */
156
+ totalSeconds(duration: Duration): number {
157
+ if (
158
+ duration.years !== 0 ||
159
+ duration.months !== 0 ||
160
+ duration.weeks !== 0 ||
161
+ duration.days !== 0
162
+ ) {
163
+ throw new Error(
164
+ "Cannot calculate total seconds for a duration with years, months, weeks, or days.",
165
+ );
166
+ }
167
+
168
+ return (
169
+ duration.seconds +
170
+ duration.minutes * 60 +
171
+ duration.hours * 60 * 60 +
172
+ duration.weeks * 7 * 24 * 60 * 60
173
+ );
174
+ },
175
+
176
+ /**
177
+ * Gets the total number of seconds in a duration.
178
+ *
179
+ * This method will throw an Error if the duration contains any years, months, weeks, or days, as those require a reference
180
+ * point to calculate the total number of seconds. It will also throw an error if any of the components are not integers.
181
+ *
182
+ * @param duration - the duration to calculate the total number of seconds for
183
+ * @returns the total number of seconds in the duration
184
+ */
185
+ totalSecondsBigInt(duration: Duration): bigint {
186
+ if (
187
+ duration.years !== 0 ||
188
+ duration.months !== 0 ||
189
+ duration.weeks !== 0 ||
190
+ duration.days !== 0
191
+ ) {
192
+ throw new Error(
193
+ "Cannot calculate total seconds for a duration with years, months, weeks, or days.",
194
+ );
195
+ }
196
+
197
+ if (
198
+ !Number.isInteger(duration.seconds) ||
199
+ !Number.isInteger(duration.minutes) ||
200
+ !Number.isInteger(duration.hours) ||
201
+ !Number.isInteger(duration.weeks)
202
+ ) {
203
+ throw new Error(
204
+ "Cannot calculate total seconds as a BigInt for a duration with non-integer components.",
205
+ );
206
+ }
207
+
208
+ return (
209
+ BigInt(duration.seconds) +
210
+ BigInt(duration.minutes) * 60n +
211
+ BigInt(duration.hours) * 60n * 60n +
212
+ BigInt(duration.weeks) * 7n * 24n * 60n * 60n
213
+ );
214
+ },
215
+
216
+ /**
217
+ * Creates a duration from a total number of seconds.
218
+ *
219
+ * The result is not normalized, so it will only contain a seconds field.
220
+ */
221
+ fromTotalSeconds(seconds: number): Duration {
222
+ return {
223
+ sign: seconds < 0 ? "-" : "+",
224
+ years: 0,
225
+ months: 0,
226
+ weeks: 0,
227
+ days: 0,
228
+ hours: 0,
229
+ minutes: 0,
230
+ seconds: Math.abs(seconds),
231
+ };
232
+ },
233
+ });
234
+
235
+ // #endregion