@usejourney/core 0.1.4 → 0.2.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/dist/index.d.ts CHANGED
@@ -28,6 +28,13 @@ declare const JourneyConfigSchema: z.ZodObject<{
28
28
  baseUrl: z.ZodOptional<z.ZodString>;
29
29
  runHistoryKeepCount: z.ZodDefault<z.ZodNumber>;
30
30
  tlsRejectUnauthorized: z.ZodDefault<z.ZodBoolean>;
31
+ /**
32
+ * Connect-phase budget (ms) covering DNS + TCP + TLS handshake. Distinct
33
+ * from the per-request timeout: that one wraps the whole fetch in an
34
+ * `AbortController`, while this one is an undici `Agent` option. Unset
35
+ * falls back to undici's 10s default; `0` disables the connect timeout.
36
+ */
37
+ connectTimeoutMs: z.ZodOptional<z.ZodNumber>;
31
38
  }, "strict", z.ZodTypeAny, {
32
39
  spec: string;
33
40
  generatedDir: string;
@@ -38,6 +45,7 @@ declare const JourneyConfigSchema: z.ZodObject<{
38
45
  name?: string | undefined;
39
46
  defaultEnvironment?: string | undefined;
40
47
  baseUrl?: string | undefined;
48
+ connectTimeoutMs?: number | undefined;
41
49
  }, {
42
50
  name?: string | undefined;
43
51
  spec?: string | undefined;
@@ -48,6 +56,7 @@ declare const JourneyConfigSchema: z.ZodObject<{
48
56
  baseUrl?: string | undefined;
49
57
  runHistoryKeepCount?: number | undefined;
50
58
  tlsRejectUnauthorized?: boolean | undefined;
59
+ connectTimeoutMs?: number | undefined;
51
60
  }>;
52
61
  type JourneyConfig = z.infer<typeof JourneyConfigSchema>;
53
62
  interface LoadedConfig {
@@ -407,6 +416,11 @@ interface HttpContext {
407
416
  * `timeoutMs` always overrides this.
408
417
  */
409
418
  defaultTimeoutMs?: number | null;
419
+ /**
420
+ * Directory that relative `fileFrom()` paths resolve against. The CLI sets
421
+ * it to the project root; unset falls back to `process.cwd()`.
422
+ */
423
+ baseDir?: string;
410
424
  }
411
425
  /** Fallback request timeout (ms) when neither a step nor `HttpContext.defaultTimeoutMs` set one. */
412
426
  declare const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
@@ -447,6 +461,14 @@ interface StepOptions<E extends Endpoint> {
447
461
  headers?: Lazy<Record<string, string>>;
448
462
  body?: Lazy<unknown>;
449
463
  timeoutMs?: number;
464
+ /**
465
+ * k6 export only — ignored by `journey run`. Overrides the slug used for this
466
+ * step's custom metrics when exporting with `--metrics-prefix <p>`: the step
467
+ * records Trend `<p>_<metric>_duration` and Counter `<p>_<metric>_tps`
468
+ * instead of names derived from the step name. `false` opts the step out of
469
+ * per-step metrics (it still counts toward `<p>_flow_duration`).
470
+ */
471
+ metric?: string | false;
450
472
  assert?: (res: HttpResponse<ResponseOf<E>>) => void | Promise<void>;
451
473
  after?: (res: HttpResponse<ResponseOf<E>>) => void | Promise<void>;
452
474
  }
@@ -706,4 +728,59 @@ type FetchResponse = Awaited<ReturnType<typeof globalThis.fetch>>;
706
728
  */
707
729
  declare function fetch$1(input: FetchInput, init?: FetchInit): Promise<FetchResponse>;
708
730
 
709
- export { AssertionError, type BuildRequestOptions, type CacheEntry, type CacheMode, type ConsoleLoggerOptions, DEFAULT_REQUEST_TIMEOUT_MS, DiskSubJourneyCache, type Endpoint, type EndpointDescriptor, type EndpointRef, type EntryJourneyOptions, type EnvValues, type Expectation, type GroupEndEvent, type GroupStartEvent, type HttpContext, type HttpMethod, type HttpResponse, type InvokeJourneyOptions, type JourneyConfig, JourneyConfigSchema, type JourneyDef, type JourneyHandle, type JourneyLogger, type JourneyOptions, type JourneyResult, type K6JourneyOptions, type LoadedConfig, type LogEvent, MemorySubJourneyCache, type PipelineNode, type PlannedNode, type RequestLog, type RequestSpec, type ResponseLog, type ResponseOf, type ReusableJourneyOptions, type RunEndEvent, type RunMeta, type RunPlannedEvent, type RunRecord, type RunStartEvent, type RunSummary, SECRET_HEADERS, type StepDef, type StepEndEvent, type StepOptions, type StepResult, type StepStartEvent, type SubJourneyCache, type SubJourneyCallDef, buildRequest, clearActiveEnvironment, clearRegistry, collectPipeline, collectSubPipeline, createConsoleLogger, createSubJourneyCache, describeError, env, execute, expect, fetch$1 as fetch, formatOpaqueBody, getCurrentCtx, getRegisteredJourneys, invokeJourney, isEndpointRef, isTextualContentType, journey, listEnvironments, listRuns, loadConfig, loadEnvironment, loggerFromEnv, maskHeaders, output, planJourney, pruneRuns, readRun, resolveBaseUrl, resolveConfigPaths, resolveUrl, runAllRegistered, runJourney, setActiveEnvironment, step, subJourneyCacheKey, tryEnv, writeRun };
731
+ /**
732
+ * Reference to a file on disk, used as a request body (raw binary upload) or
733
+ * as a part of a `multipart()` body. Created by `fileFrom()`; the bytes are
734
+ * read lazily at request time by the Node runner, and at k6 init time (via
735
+ * `open(path, "b")`) by the k6 export.
736
+ */
737
+ interface FileRef {
738
+ readonly __journeyFile: true;
739
+ /** Path as written in the journey — relative paths resolve against the project root. */
740
+ readonly path: string;
741
+ /** MIME type sent for the file. Defaults to `application/octet-stream`. */
742
+ readonly contentType?: string;
743
+ /** Filename reported in a multipart part. Defaults to the path's basename. */
744
+ readonly filename?: string;
745
+ }
746
+ /** A value accepted as one part of a `multipart()` body. */
747
+ type MultipartValue = string | number | boolean | FileRef;
748
+ /** A `multipart/form-data` request body. Created by `multipart()`. */
749
+ interface MultipartBody {
750
+ readonly __journeyMultipart: true;
751
+ readonly parts: Readonly<Record<string, MultipartValue>>;
752
+ }
753
+ /**
754
+ * Reference a file to upload. Relative paths resolve against the **project
755
+ * root** (the directory holding `journey.config.json`), not the journey file.
756
+ *
757
+ * - As a step `body`, the file is sent as the raw request body with
758
+ * `Content-Type: <contentType>` (default `application/octet-stream`).
759
+ * - As a `multipart()` part, it becomes a file part with `filename` (default:
760
+ * the path's basename) and `contentType`.
761
+ *
762
+ * For `journey export k6`, the path should be a string literal so the export
763
+ * can pre-load it with `open(path, "b")` in the k6 init context.
764
+ *
765
+ * @example
766
+ * step("upload", {
767
+ * endpoint: endpoints.virusScan,
768
+ * body: () => multipart({ file: fileFrom("fixtures/sample.png", "image/png") }),
769
+ * });
770
+ */
771
+ declare function fileFrom(path: string, contentType?: string, filename?: string): FileRef;
772
+ /**
773
+ * Build a `multipart/form-data` body. String/number/boolean values become text
774
+ * fields; `fileFrom(...)` values become file parts. The runner (and k6) set
775
+ * the `Content-Type: multipart/form-data; boundary=…` header — any
776
+ * `Content-Type` supplied in the step's `headers` is dropped for multipart
777
+ * bodies, because a hand-written one can't carry the generated boundary.
778
+ *
779
+ * @example
780
+ * body: () => multipart({ description: "id card", file: fileFrom("fixtures/id.png", "image/png") })
781
+ */
782
+ declare function multipart(parts: Record<string, MultipartValue>): MultipartBody;
783
+ declare function isFileRef(value: unknown): value is FileRef;
784
+ declare function isMultipartBody(value: unknown): value is MultipartBody;
785
+
786
+ export { AssertionError, type BuildRequestOptions, type CacheEntry, type CacheMode, type ConsoleLoggerOptions, DEFAULT_REQUEST_TIMEOUT_MS, DiskSubJourneyCache, type Endpoint, type EndpointDescriptor, type EndpointRef, type EntryJourneyOptions, type EnvValues, type Expectation, type FileRef, type GroupEndEvent, type GroupStartEvent, type HttpContext, type HttpMethod, type HttpResponse, type InvokeJourneyOptions, type JourneyConfig, JourneyConfigSchema, type JourneyDef, type JourneyHandle, type JourneyLogger, type JourneyOptions, type JourneyResult, type K6JourneyOptions, type LoadedConfig, type LogEvent, MemorySubJourneyCache, type MultipartBody, type MultipartValue, type PipelineNode, type PlannedNode, type RequestLog, type RequestSpec, type ResponseLog, type ResponseOf, type ReusableJourneyOptions, type RunEndEvent, type RunMeta, type RunPlannedEvent, type RunRecord, type RunStartEvent, type RunSummary, SECRET_HEADERS, type StepDef, type StepEndEvent, type StepOptions, type StepResult, type StepStartEvent, type SubJourneyCache, type SubJourneyCallDef, buildRequest, clearActiveEnvironment, clearRegistry, collectPipeline, collectSubPipeline, createConsoleLogger, createSubJourneyCache, describeError, env, execute, expect, fetch$1 as fetch, fileFrom, formatOpaqueBody, getCurrentCtx, getRegisteredJourneys, invokeJourney, isEndpointRef, isFileRef, isMultipartBody, isTextualContentType, journey, listEnvironments, listRuns, loadConfig, loadEnvironment, loggerFromEnv, maskHeaders, multipart, output, planJourney, pruneRuns, readRun, resolveBaseUrl, resolveConfigPaths, resolveUrl, runAllRegistered, runJourney, setActiveEnvironment, step, subJourneyCacheKey, tryEnv, writeRun };
package/dist/index.js CHANGED
@@ -78,7 +78,14 @@ var JourneyConfigSchema = z.object({
78
78
  defaultEnvironment: z.string().min(1).optional(),
79
79
  baseUrl: z.string().url().optional(),
80
80
  runHistoryKeepCount: z.number().int().min(0).default(20),
81
- tlsRejectUnauthorized: z.boolean().default(true)
81
+ tlsRejectUnauthorized: z.boolean().default(true),
82
+ /**
83
+ * Connect-phase budget (ms) covering DNS + TCP + TLS handshake. Distinct
84
+ * from the per-request timeout: that one wraps the whole fetch in an
85
+ * `AbortController`, while this one is an undici `Agent` option. Unset
86
+ * falls back to undici's 10s default; `0` disables the connect timeout.
87
+ */
88
+ connectTimeoutMs: z.number().int().min(0).optional()
82
89
  }).strict();
83
90
  function resolveRelative(projectDir, value) {
84
91
  return isAbsolute(value) ? value : join2(projectDir, value);
@@ -391,6 +398,119 @@ function loggerFromEnv(env2 = process.env) {
391
398
  return void 0;
392
399
  }
393
400
 
401
+ // src/body.ts
402
+ import { randomBytes } from "crypto";
403
+ import { readFile as readFile4 } from "fs/promises";
404
+ import { basename, isAbsolute as isAbsolute2, resolve } from "path";
405
+ function fileFrom(path, contentType, filename) {
406
+ if (typeof path !== "string" || path.length === 0) {
407
+ throw new TypeError("fileFrom(path): path must be a non-empty string");
408
+ }
409
+ return {
410
+ __journeyFile: true,
411
+ path,
412
+ ...contentType !== void 0 ? { contentType } : {},
413
+ ...filename !== void 0 ? { filename } : {}
414
+ };
415
+ }
416
+ function multipart(parts) {
417
+ if (parts === null || typeof parts !== "object") {
418
+ throw new TypeError("multipart(parts): parts must be an object");
419
+ }
420
+ return { __journeyMultipart: true, parts: { ...parts } };
421
+ }
422
+ function isFileRef(value) {
423
+ return typeof value === "object" && value !== null && value.__journeyFile === true;
424
+ }
425
+ function isMultipartBody(value) {
426
+ return typeof value === "object" && value !== null && value.__journeyMultipart === true;
427
+ }
428
+ function isBinary(value) {
429
+ return value instanceof ArrayBuffer || ArrayBuffer.isView(value) || typeof Blob !== "undefined" && value instanceof Blob;
430
+ }
431
+ function defaultContentType(body) {
432
+ if (isMultipartBody(body)) return null;
433
+ if (isFileRef(body)) return body.contentType ?? "application/octet-stream";
434
+ if (isBinary(body)) {
435
+ if (typeof Blob !== "undefined" && body instanceof Blob && body.type) return body.type;
436
+ return "application/octet-stream";
437
+ }
438
+ return "application/json";
439
+ }
440
+ function filePath(ref, baseDir) {
441
+ return isAbsolute2(ref.path) ? ref.path : resolve(baseDir ?? process.cwd(), ref.path);
442
+ }
443
+ async function readFileRef(ref, baseDir) {
444
+ const abs = filePath(ref, baseDir);
445
+ try {
446
+ return await readFile4(abs);
447
+ } catch (err) {
448
+ throw new Error(
449
+ `fileFrom(${JSON.stringify(ref.path)}): cannot read ${abs}: ${err.message}`
450
+ );
451
+ }
452
+ }
453
+ function escapeDisposition(v) {
454
+ return v.replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/"/g, "%22");
455
+ }
456
+ async function encodeMultipart(body, baseDir) {
457
+ const boundary = `----JourneyBoundary${randomBytes(12).toString("hex")}`;
458
+ const chunks = [];
459
+ const text = (s) => chunks.push(Buffer.from(s, "utf8"));
460
+ for (const [name, value] of Object.entries(body.parts)) {
461
+ text(`--${boundary}\r
462
+ `);
463
+ if (isFileRef(value)) {
464
+ const bytes = await readFileRef(value, baseDir);
465
+ const filename = value.filename ?? basename(value.path);
466
+ text(
467
+ `Content-Disposition: form-data; name="${escapeDisposition(name)}"; filename="${escapeDisposition(filename)}"\r
468
+ Content-Type: ${value.contentType ?? "application/octet-stream"}\r
469
+ \r
470
+ `
471
+ );
472
+ chunks.push(bytes);
473
+ text("\r\n");
474
+ } else {
475
+ text(
476
+ `Content-Disposition: form-data; name="${escapeDisposition(name)}"\r
477
+ \r
478
+ ${String(value)}\r
479
+ `
480
+ );
481
+ }
482
+ }
483
+ text(`--${boundary}--\r
484
+ `);
485
+ return {
486
+ body: Buffer.concat(chunks),
487
+ contentType: `multipart/form-data; boundary=${boundary}`
488
+ };
489
+ }
490
+ async function encodeBody(body, baseDir) {
491
+ if (typeof body === "string") return { body };
492
+ if (isMultipartBody(body)) return encodeMultipart(body, baseDir);
493
+ if (isFileRef(body)) return { body: await readFileRef(body, baseDir) };
494
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
495
+ return { body: new Uint8Array(await body.arrayBuffer()) };
496
+ }
497
+ if (isBinary(body)) return { body };
498
+ return { body: JSON.stringify(body) };
499
+ }
500
+ function describeBodyForLog(body) {
501
+ const describeFile = (f) => `<file ${f.path}${f.contentType ? ` (${f.contentType})` : ""}>`;
502
+ if (isMultipartBody(body)) {
503
+ const parts = {};
504
+ for (const [k, v] of Object.entries(body.parts)) parts[k] = isFileRef(v) ? describeFile(v) : v;
505
+ return { "<multipart>": parts };
506
+ }
507
+ if (isFileRef(body)) return describeFile(body);
508
+ if (body instanceof ArrayBuffer) return `<binary ${body.byteLength} bytes>`;
509
+ if (ArrayBuffer.isView(body)) return `<binary ${body.byteLength} bytes>`;
510
+ if (typeof Blob !== "undefined" && body instanceof Blob) return `<binary ${body.size} bytes>`;
511
+ return body;
512
+ }
513
+
394
514
  // src/http.ts
395
515
  var DEFAULT_REQUEST_TIMEOUT_MS = 6e4;
396
516
  function resolveUrl(endpoint, ctx, params, query) {
@@ -432,8 +552,15 @@ function buildRequest(opts, ctx) {
432
552
  ...opts.headers ?? {}
433
553
  };
434
554
  const hasBody = opts.body !== void 0;
435
- if (hasBody && !("content-type" in lower(headers))) {
436
- headers["Content-Type"] = "application/json";
555
+ if (hasBody) {
556
+ const contentType = defaultContentType(opts.body);
557
+ if (contentType === null) {
558
+ for (const k of Object.keys(headers)) {
559
+ if (k.toLowerCase() === "content-type") delete headers[k];
560
+ }
561
+ } else if (!("content-type" in lower(headers))) {
562
+ headers["Content-Type"] = contentType;
563
+ }
437
564
  }
438
565
  const spec = { method: opts.endpoint.method, url, headers };
439
566
  if (hasBody) spec.body = opts.body;
@@ -451,13 +578,23 @@ async function execute(req, ctx) {
451
578
  method: req.method,
452
579
  url: req.url,
453
580
  headers: req.headers,
454
- ...req.body !== void 0 ? { body: req.body } : {}
581
+ ...req.body !== void 0 ? { body: describeBodyForLog(req.body) } : {}
455
582
  };
456
583
  ctx.logger?.onRequest?.(logReq);
457
584
  const f = ctx.fetchImpl ?? fetch;
458
585
  const init = { method: req.method, headers: req.headers };
459
586
  if (req.body !== void 0) {
460
- init.body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
587
+ let encoded;
588
+ try {
589
+ encoded = await encodeBody(req.body, ctx.baseDir);
590
+ } catch (err) {
591
+ ctx.logger?.onError?.(logReq, err, 0);
592
+ throw err;
593
+ }
594
+ init.body = encoded.body;
595
+ if (encoded.contentType !== void 0) {
596
+ init.headers = { ...req.headers, "Content-Type": encoded.contentType };
597
+ }
461
598
  }
462
599
  if (ctx.dispatcher !== void 0) {
463
600
  init.dispatcher = ctx.dispatcher;
@@ -494,7 +631,7 @@ async function execute(req, ctx) {
494
631
 
495
632
  // src/cache.ts
496
633
  import { createHash } from "crypto";
497
- import { mkdir as mkdir2, readFile as readFile4, rm, writeFile as writeFile2 } from "fs/promises";
634
+ import { mkdir as mkdir2, readFile as readFile5, rm, writeFile as writeFile2 } from "fs/promises";
498
635
  import { join as join4 } from "path";
499
636
  function subJourneyCacheKey(journeyName, resolvedKey) {
500
637
  return `${journeyName}:${resolvedKey}`;
@@ -535,7 +672,7 @@ var DiskSubJourneyCache = class {
535
672
  const path = this.pathFor(key);
536
673
  let raw;
537
674
  try {
538
- raw = await readFile4(path, "utf8");
675
+ raw = await readFile5(path, "utf8");
539
676
  } catch {
540
677
  return void 0;
541
678
  }
@@ -1208,11 +1345,14 @@ export {
1208
1345
  execute,
1209
1346
  expect,
1210
1347
  fetch2 as fetch,
1348
+ fileFrom,
1211
1349
  formatOpaqueBody,
1212
1350
  getCurrentCtx,
1213
1351
  getRegisteredJourneys,
1214
1352
  invokeJourney,
1215
1353
  isEndpointRef,
1354
+ isFileRef,
1355
+ isMultipartBody,
1216
1356
  isTextualContentType,
1217
1357
  journey,
1218
1358
  listEnvironments,
@@ -1221,6 +1361,7 @@ export {
1221
1361
  loadEnvironment,
1222
1362
  loggerFromEnv,
1223
1363
  maskHeaders,
1364
+ multipart,
1224
1365
  output,
1225
1366
  planJourney,
1226
1367
  pruneRuns,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usejourney/core",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",