@usejourney/k6-adapter 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 femoral <me@femoral.dev>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ interface ExportK6Options {
2
+ /** Absolute or cwd-relative path to the `.journey.ts` file. */
3
+ journeyFile: string;
4
+ /** Output path for the emitted k6 script. Wins over `outDir` when both are set. */
5
+ outFile?: string;
6
+ /** Output directory; emitted file is `<outDir>/<basename>.k6.js`. Ignored when `outFile` is set. */
7
+ outDir?: string;
8
+ /**
9
+ * k6 options block to bake into the emitted script as `export const options = {...}`.
10
+ * Same shape as `JourneyOptions['k6']` from `@usejourney/core`; kept loose here so the
11
+ * adapter does not pull a runtime dep on core. JSON-serialized verbatim.
12
+ */
13
+ k6Options?: Record<string, unknown>;
14
+ }
15
+ interface ExportK6Result {
16
+ outFile: string;
17
+ source: string;
18
+ }
19
+ declare function exportToK6(opts: ExportK6Options): Promise<ExportK6Result>;
20
+
21
+ export { type ExportK6Options, type ExportK6Result, exportToK6 };
package/dist/index.js ADDED
@@ -0,0 +1,343 @@
1
+ // src/index.ts
2
+ import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
3
+ import { basename, dirname as dirname2, resolve as resolve2 } from "path";
4
+ import { transform } from "esbuild";
5
+
6
+ // src/inline.ts
7
+ import { readFile } from "fs/promises";
8
+ import { dirname, isAbsolute, resolve } from "path";
9
+ var IMPORT_RE = /^\s*import\s+[^;]*?from\s+["']([^"']+)["'];?\s*$/gm;
10
+ function stripCoreImports(source) {
11
+ return source.replace(IMPORT_RE, (match, specifier) => {
12
+ if (specifier === "@usejourney/core") return "";
13
+ return match;
14
+ });
15
+ }
16
+ function stripTypeImports(source) {
17
+ return source.replace(/^\s*import\s+type\s+[^;]*?from\s+["'][^"']+["'];?\s*$/gm, "");
18
+ }
19
+ function findRelativeImports(source, fromFile) {
20
+ const out = [];
21
+ IMPORT_RE.lastIndex = 0;
22
+ let m;
23
+ while (m = IMPORT_RE.exec(source)) {
24
+ const spec = m[1];
25
+ if (!spec.startsWith(".")) continue;
26
+ const base = dirname(fromFile);
27
+ const abs = isAbsolute(spec) ? spec : resolve(base, spec);
28
+ const withExt = abs.endsWith(".ts") || abs.endsWith(".js") ? abs : null;
29
+ const candidates = withExt ? [withExt] : [`${abs}.ts`, `${abs}.js`, abs.replace(/\.js$/, ".ts")];
30
+ out.push({ specifier: spec, sourcePath: candidates[0] });
31
+ }
32
+ return out;
33
+ }
34
+ async function inlineRelativeImports(journeySource, journeyPath, visited = /* @__PURE__ */ new Set()) {
35
+ const imports = findRelativeImports(journeySource, journeyPath);
36
+ let out = journeySource;
37
+ for (const imp of imports) {
38
+ const tryPaths = imp.sourcePath.endsWith(".ts") || imp.sourcePath.endsWith(".js") ? [imp.sourcePath, imp.sourcePath.replace(/\.js$/, ".ts")] : [`${imp.sourcePath}.ts`, `${imp.sourcePath}.js`];
39
+ let resolved;
40
+ for (const p of tryPaths) {
41
+ try {
42
+ const content = await readFile(p, "utf8");
43
+ resolved = { path: p, content };
44
+ break;
45
+ } catch {
46
+ }
47
+ }
48
+ const importLineRe = new RegExp(
49
+ `^\\s*import\\s+[^;]*?from\\s+["']${imp.specifier.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}["'];?\\s*$`,
50
+ "m"
51
+ );
52
+ if (!resolved) continue;
53
+ if (visited.has(resolved.path)) {
54
+ out = out.replace(importLineRe, "");
55
+ continue;
56
+ }
57
+ visited.add(resolved.path);
58
+ const cleaned = stripCoreImports(stripTypeImports(resolved.content));
59
+ const recursed = await inlineRelativeImports(cleaned, resolved.path, visited);
60
+ const converted = recursed.replace(/^\s*export\s+(const|function|class)\s+/gm, "$1 ");
61
+ out = out.replace(
62
+ importLineRe,
63
+ `
64
+ // ----- inlined from ${imp.specifier} -----
65
+ ${converted}
66
+ // ----- end inlined -----
67
+ `
68
+ );
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // src/shim.ts
74
+ var SHIM_SOURCE = `import http from "k6/http";
75
+ import { check, group } from "k6";
76
+
77
+ const __BASE_URL = __ENV.JOURNEY_BASE_URL || "";
78
+ const __journeys = [];
79
+ let __currentNodes = null;
80
+ let __outputSlot = null;
81
+ const __MAX_SUB_DEPTH = 8;
82
+
83
+ // Sub-journey output cache \u2014 mirrors the runtime's MemorySubJourneyCache.
84
+ // Scope is per-VU: k6 gives each VU its own JS runtime, and module-scoped state
85
+ // persists across that VU's iterations (there is no cross-VU mutable shared
86
+ // state \u2014 SharedArray is read-only). So a cached value is reused across the
87
+ // iterations of one VU; each VU warms its own copy. Set JOURNEY_CACHE=off to
88
+ // force every iteration cold (true per-iteration cost for load measurement).
89
+ const __subCache = {};
90
+ const __cacheOff = __ENV.JOURNEY_CACHE === "off";
91
+ function __cacheGet(key) {
92
+ const e = __subCache[key];
93
+ if (!e) return undefined;
94
+ if (e.expiresAt !== undefined && Date.now() >= e.expiresAt) {
95
+ delete __subCache[key];
96
+ return undefined;
97
+ }
98
+ return e;
99
+ }
100
+ function __cacheSet(key, value, ttlMs) {
101
+ __subCache[key] = ttlMs !== undefined ? { value: value, expiresAt: Date.now() + ttlMs } : { value: value };
102
+ }
103
+
104
+ // Minimal no-op stand-in for zod. Reusable journeys declare \`inputs\` / \`outputs\`
105
+ // schemas via \`z\`, but k6 has no zod runtime and the schemas are not enforced
106
+ // in exported scripts \u2014 every access or call returns the same chainable proxy.
107
+ const z = new Proxy(function () {}, {
108
+ get() { return z; },
109
+ apply() { return z; },
110
+ construct() { return z; },
111
+ });
112
+
113
+ // Sub-journey cacheKey / cacheTtlMs / cache opts ARE honored \u2014 see __runSub
114
+ // and the per-VU note on __subCache above.
115
+ function journey(name, optsOrBody, maybeBody) {
116
+ const body = typeof optsOrBody === "function" ? optsOrBody : maybeBody;
117
+ const opts = typeof optsOrBody === "function" ? null : optsOrBody;
118
+ if (opts && opts.reusable === true) {
119
+ return { __journeyHandle: true, name, body };
120
+ }
121
+ __journeys.push({ name, body });
122
+ }
123
+
124
+ function step(name, opts) {
125
+ if (!__currentNodes) {
126
+ throw new Error("step(" + JSON.stringify(name) + ") called outside a journey(...) body");
127
+ }
128
+ __currentNodes.push({ kind: "step", name, opts });
129
+ }
130
+
131
+ function invokeJourney(handle, opts) {
132
+ if (!__currentNodes) {
133
+ throw new Error("invokeJourney(...) called outside a journey(...) body");
134
+ }
135
+ __currentNodes.push({ kind: "sub", handle, opts: opts || {} });
136
+ }
137
+
138
+ function output(value) {
139
+ if (__outputSlot) __outputSlot.value = value;
140
+ }
141
+
142
+ function env(key) {
143
+ const v = __ENV[key];
144
+ if (v === undefined) throw new Error("env: missing " + key);
145
+ return v;
146
+ }
147
+
148
+ function deepEqual(a, b) {
149
+ if (Object.is(a, b)) return true;
150
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
151
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
152
+ const ak = Object.keys(a); const bk = Object.keys(b);
153
+ if (ak.length !== bk.length) return false;
154
+ for (const k of ak) if (!deepEqual(a[k], b[k])) return false;
155
+ return true;
156
+ }
157
+
158
+ function expect(value) {
159
+ const fail = (msg) => { const e = new Error(msg); e.name = "AssertionError"; throw e; };
160
+ return {
161
+ toBe(expected) { if (!Object.is(value, expected)) fail("expected " + JSON.stringify(value) + " to be " + JSON.stringify(expected)); },
162
+ toEqual(expected) { if (!deepEqual(value, expected)) fail("expected " + JSON.stringify(value) + " to equal " + JSON.stringify(expected)); },
163
+ toBeDefined() { if (value === undefined) fail("expected value to be defined"); },
164
+ toContain(expected) {
165
+ if (typeof value === "string") { if (!value.includes(expected)) fail("expected string to contain " + JSON.stringify(expected)); return; }
166
+ if (Array.isArray(value)) { if (!value.some((v) => deepEqual(v, expected))) fail("expected array to contain " + JSON.stringify(expected)); return; }
167
+ fail("toContain only supports strings and arrays");
168
+ },
169
+ toMatch(expected) {
170
+ if (typeof value !== "string") fail("toMatch only supports strings");
171
+ const re = typeof expected === "string" ? new RegExp(expected) : expected;
172
+ if (!re.test(value)) fail("expected " + JSON.stringify(value) + " to match " + re);
173
+ },
174
+ };
175
+ }
176
+
177
+ function __parseBody(res) {
178
+ const ct = (res.headers && (res.headers["Content-Type"] || res.headers["content-type"])) || "";
179
+ if (ct.indexOf("json") !== -1) { try { return res.json(); } catch (e) { return null; } }
180
+ return res.body;
181
+ }
182
+
183
+ function __interpolate(path, params) {
184
+ if (!params) return path;
185
+ let out = path;
186
+ for (const k of Object.keys(params)) out = out.replace("{" + k + "}", encodeURIComponent(String(params[k])));
187
+ return out;
188
+ }
189
+
190
+ function __mergeHeaders(h) {
191
+ const out = {};
192
+ if (h) for (const k of Object.keys(h)) out[k] = h[k];
193
+ return out;
194
+ }
195
+
196
+ function __buildQuery(query) {
197
+ if (!query) return "";
198
+ const parts = [];
199
+ for (const k of Object.keys(query)) {
200
+ const v = query[k];
201
+ if (v !== undefined) parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v)));
202
+ }
203
+ return parts.length ? "?" + parts.join("&") : "";
204
+ }
205
+
206
+ // Evaluate a journey body, collecting its pipeline nodes (step + sub).
207
+ function __collectNodes(body, input) {
208
+ const nodes = [];
209
+ const prev = __currentNodes;
210
+ __currentNodes = nodes;
211
+ try { body(input); } finally { __currentNodes = prev; }
212
+ return nodes;
213
+ }
214
+
215
+ function __executeStep(current, journeyName) {
216
+ const opts = current.opts;
217
+ const headers = typeof opts.headers === "function" ? opts.headers() : (opts.headers || {});
218
+ const body = typeof opts.body === "function" ? opts.body() : opts.body;
219
+ // params and query are lazy in the core runtime \u2014 resolve a function form
220
+ // before interpolating the path / building the query string, like headers/body.
221
+ const params = typeof opts.params === "function" ? opts.params() : opts.params;
222
+ const query = typeof opts.query === "function" ? opts.query() : opts.query;
223
+ const path = __interpolate(opts.endpoint.path, params);
224
+ const base = opts.endpoint.baseUrl || __BASE_URL;
225
+ const url = base + path + __buildQuery(query);
226
+ const requestHeaders = __mergeHeaders(headers);
227
+ if (body !== undefined && !("Content-Type" in requestHeaders) && !("content-type" in requestHeaders)) {
228
+ requestHeaders["Content-Type"] = "application/json";
229
+ }
230
+ const res = http.request(
231
+ opts.endpoint.method,
232
+ url,
233
+ body !== undefined ? (typeof body === "string" ? body : JSON.stringify(body)) : null,
234
+ { headers: requestHeaders },
235
+ );
236
+ const wrapped = { status: res.status, headers: res.headers, body: __parseBody(res) };
237
+ if (opts.assert) {
238
+ const name = journeyName + " \u203A " + current.name;
239
+ check(wrapped, {
240
+ [name]: (r) => {
241
+ try { opts.assert(r); return true; } catch (e) { console.error(name + ": " + (e && e.message ? e.message : e)); return false; }
242
+ },
243
+ });
244
+ }
245
+ if (opts.after) opts.after(wrapped);
246
+ }
247
+
248
+ // Inline a sub-journey node under a k6 group() named after the child journey.
249
+ // On a cache hit the child's requests are skipped entirely and the stored
250
+ // output is replayed; the parent's assert/after still run either way.
251
+ function __runSub(node, depth) {
252
+ if (depth >= __MAX_SUB_DEPTH) {
253
+ throw new Error("invokeJourney: sub-journey nesting exceeded " + __MAX_SUB_DEPTH + " levels");
254
+ }
255
+ const handle = node.handle;
256
+ const opts = node.opts || {};
257
+ const label = opts.name || handle.name;
258
+ let input = opts.inputs;
259
+ if (typeof input === "function") input = input();
260
+
261
+ // Cache active only when a cacheKey is supplied and not disabled \u2014 mirrors
262
+ // the core runtime. Composite key is childName:resolvedKey.
263
+ let resolvedKey;
264
+ if (opts.cacheKey !== undefined) {
265
+ resolvedKey = typeof opts.cacheKey === "function" ? opts.cacheKey(input) : opts.cacheKey;
266
+ }
267
+ const cacheKeyStr =
268
+ !__cacheOff && resolvedKey !== undefined && opts.cache !== "off"
269
+ ? handle.name + ":" + resolvedKey
270
+ : undefined;
271
+
272
+ const prevSlot = __outputSlot;
273
+ const slot = { value: undefined };
274
+ __outputSlot = slot;
275
+ try {
276
+ let hit = false;
277
+ if (cacheKeyStr !== undefined) {
278
+ const entry = __cacheGet(cacheKeyStr);
279
+ if (entry !== undefined) { slot.value = entry.value; hit = true; }
280
+ }
281
+ if (!hit) {
282
+ const childNodes = __collectNodes(handle.body, input);
283
+ group(label, () => { __runNodes(childNodes, handle.name, depth + 1); });
284
+ if (cacheKeyStr !== undefined) __cacheSet(cacheKeyStr, slot.value, opts.cacheTtlMs);
285
+ }
286
+ } finally {
287
+ __outputSlot = prevSlot;
288
+ }
289
+ if (opts.assert) opts.assert(slot.value);
290
+ if (opts.after) opts.after(slot.value);
291
+ }
292
+
293
+ function __runNodes(nodes, journeyName, depth) {
294
+ for (let i = 0; i < nodes.length; i++) {
295
+ const node = nodes[i];
296
+ if (node.kind === "sub") __runSub(node, depth);
297
+ else __executeStep(node, journeyName);
298
+ }
299
+ }
300
+ `;
301
+ var ENTRY_SOURCE = `
302
+ export default function () {
303
+ for (let i = 0; i < __journeys.length; i++) {
304
+ const j = __journeys[i];
305
+ const nodes = __collectNodes(j.body, undefined);
306
+ __runNodes(nodes, j.name, 0);
307
+ }
308
+ }
309
+ `;
310
+
311
+ // src/index.ts
312
+ var HEADER = `// AUTO-GENERATED BY @usejourney/k6-adapter \u2014 do not edit by hand.
313
+ // Usage: JOURNEY_BASE_URL=https://api.example.com k6 run <this-file>
314
+ `;
315
+ async function exportToK6(opts) {
316
+ const journeyPath = resolve2(process.cwd(), opts.journeyFile);
317
+ const rawSource = await readFile2(journeyPath, "utf8");
318
+ const withoutTypeImports = stripTypeImports(rawSource);
319
+ const inlined = await inlineRelativeImports(withoutTypeImports, journeyPath);
320
+ const withoutCore = stripCoreImports(inlined);
321
+ const { code: userCode } = await transform(withoutCore, {
322
+ loader: "ts",
323
+ format: "esm"
324
+ });
325
+ const fileBase = basename(journeyPath).replace(/\.journey\.ts$/, "");
326
+ const outFile = opts.outFile ? resolve2(process.cwd(), opts.outFile) : opts.outDir ? resolve2(process.cwd(), opts.outDir, `${fileBase}.k6.js`) : resolve2(dirname2(journeyPath), `${fileBase}.k6.js`);
327
+ const optionsBlock = opts.k6Options ? `export const options = ${JSON.stringify(opts.k6Options, null, 2)};
328
+ ` : "";
329
+ const source = [
330
+ HEADER,
331
+ SHIM_SOURCE,
332
+ optionsBlock,
333
+ "// ===== user journey =====",
334
+ userCode.trimStart(),
335
+ ENTRY_SOURCE
336
+ ].join("\n");
337
+ await mkdir(dirname2(outFile), { recursive: true });
338
+ await writeFile(outFile, source, "utf8");
339
+ return { outFile, source };
340
+ }
341
+ export {
342
+ exportToK6
343
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@usejourney/k6-adapter",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/femoral/journey.git",
8
+ "directory": "packages/k6-adapter"
9
+ },
10
+ "homepage": "https://github.com/femoral/journey#readme",
11
+ "bugs": "https://github.com/femoral/journey/issues",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "esbuild": "^0.21.5"
29
+ },
30
+ "devDependencies": {
31
+ "@usejourney/core": "0.1.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup src/index.ts --format esm --dts --clean",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run --passWithNoTests"
37
+ }
38
+ }