@usejourney/core 0.1.5 → 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
@@ -416,6 +416,11 @@ interface HttpContext {
416
416
  * `timeoutMs` always overrides this.
417
417
  */
418
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;
419
424
  }
420
425
  /** Fallback request timeout (ms) when neither a step nor `HttpContext.defaultTimeoutMs` set one. */
421
426
  declare const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
@@ -456,6 +461,14 @@ interface StepOptions<E extends Endpoint> {
456
461
  headers?: Lazy<Record<string, string>>;
457
462
  body?: Lazy<unknown>;
458
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;
459
472
  assert?: (res: HttpResponse<ResponseOf<E>>) => void | Promise<void>;
460
473
  after?: (res: HttpResponse<ResponseOf<E>>) => void | Promise<void>;
461
474
  }
@@ -715,4 +728,59 @@ type FetchResponse = Awaited<ReturnType<typeof globalThis.fetch>>;
715
728
  */
716
729
  declare function fetch$1(input: FetchInput, init?: FetchInit): Promise<FetchResponse>;
717
730
 
718
- 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
@@ -398,6 +398,119 @@ function loggerFromEnv(env2 = process.env) {
398
398
  return void 0;
399
399
  }
400
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
+
401
514
  // src/http.ts
402
515
  var DEFAULT_REQUEST_TIMEOUT_MS = 6e4;
403
516
  function resolveUrl(endpoint, ctx, params, query) {
@@ -439,8 +552,15 @@ function buildRequest(opts, ctx) {
439
552
  ...opts.headers ?? {}
440
553
  };
441
554
  const hasBody = opts.body !== void 0;
442
- if (hasBody && !("content-type" in lower(headers))) {
443
- 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
+ }
444
564
  }
445
565
  const spec = { method: opts.endpoint.method, url, headers };
446
566
  if (hasBody) spec.body = opts.body;
@@ -458,13 +578,23 @@ async function execute(req, ctx) {
458
578
  method: req.method,
459
579
  url: req.url,
460
580
  headers: req.headers,
461
- ...req.body !== void 0 ? { body: req.body } : {}
581
+ ...req.body !== void 0 ? { body: describeBodyForLog(req.body) } : {}
462
582
  };
463
583
  ctx.logger?.onRequest?.(logReq);
464
584
  const f = ctx.fetchImpl ?? fetch;
465
585
  const init = { method: req.method, headers: req.headers };
466
586
  if (req.body !== void 0) {
467
- 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
+ }
468
598
  }
469
599
  if (ctx.dispatcher !== void 0) {
470
600
  init.dispatcher = ctx.dispatcher;
@@ -501,7 +631,7 @@ async function execute(req, ctx) {
501
631
 
502
632
  // src/cache.ts
503
633
  import { createHash } from "crypto";
504
- 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";
505
635
  import { join as join4 } from "path";
506
636
  function subJourneyCacheKey(journeyName, resolvedKey) {
507
637
  return `${journeyName}:${resolvedKey}`;
@@ -542,7 +672,7 @@ var DiskSubJourneyCache = class {
542
672
  const path = this.pathFor(key);
543
673
  let raw;
544
674
  try {
545
- raw = await readFile4(path, "utf8");
675
+ raw = await readFile5(path, "utf8");
546
676
  } catch {
547
677
  return void 0;
548
678
  }
@@ -1215,11 +1345,14 @@ export {
1215
1345
  execute,
1216
1346
  expect,
1217
1347
  fetch2 as fetch,
1348
+ fileFrom,
1218
1349
  formatOpaqueBody,
1219
1350
  getCurrentCtx,
1220
1351
  getRegisteredJourneys,
1221
1352
  invokeJourney,
1222
1353
  isEndpointRef,
1354
+ isFileRef,
1355
+ isMultipartBody,
1223
1356
  isTextualContentType,
1224
1357
  journey,
1225
1358
  listEnvironments,
@@ -1228,6 +1361,7 @@ export {
1228
1361
  loadEnvironment,
1229
1362
  loggerFromEnv,
1230
1363
  maskHeaders,
1364
+ multipart,
1231
1365
  output,
1232
1366
  planJourney,
1233
1367
  pruneRuns,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usejourney/core",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",