@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 +78 -1
- package/dist/index.js +148 -7
- package/package.json +1 -1
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
|
-
|
|
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
|
|
436
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|