@qaick/http-executor 0.1.0-alpha.1 → 0.1.0-alpha.3
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/http-action-executor.d.ts +32 -0
- package/dist/http-action-executor.js +148 -2
- package/package.json +41 -10
|
@@ -13,6 +13,36 @@ export interface HttpTransport {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
}): Promise<HttpResponse>;
|
|
15
15
|
}
|
|
16
|
+
/** Internal Runtime-to-HTTP execution key. It is not part of persisted Action data. */
|
|
17
|
+
export declare const HTTP_EXECUTION_ID_KEY = "__qaick_execution_id";
|
|
18
|
+
export interface HttpCookie {
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
domain: string;
|
|
22
|
+
path: string;
|
|
23
|
+
hostOnly: boolean;
|
|
24
|
+
secure: boolean;
|
|
25
|
+
expiresAt?: number;
|
|
26
|
+
}
|
|
27
|
+
/** Cookie state shared by HTTP Actions during one execution. */
|
|
28
|
+
export declare class HttpCookieJar {
|
|
29
|
+
private readonly cookies;
|
|
30
|
+
store(url: string, setCookie: string | string[] | undefined): void;
|
|
31
|
+
header(url: string): string | undefined;
|
|
32
|
+
}
|
|
33
|
+
/** HTTP-specific session boundary. One instance must be created per QAick execution. */
|
|
34
|
+
export declare class HttpExecutionSession implements HttpTransport {
|
|
35
|
+
private readonly transport;
|
|
36
|
+
readonly cookies: HttpCookieJar;
|
|
37
|
+
constructor(transport: HttpTransport, cookies?: HttpCookieJar);
|
|
38
|
+
request(url: string, init: {
|
|
39
|
+
method: string;
|
|
40
|
+
body?: unknown;
|
|
41
|
+
headers?: Record<string, string>;
|
|
42
|
+
signal: AbortSignal;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}): Promise<HttpResponse>;
|
|
45
|
+
}
|
|
16
46
|
export type HttpActionErrorCode = 'HTTP_INVALID_URL' | 'HTTP_REQUEST_CONSTRUCTION_ERROR' | 'HTTP_ABORTED' | 'HTTP_TIMEOUT' | 'HTTP_NETWORK_ERROR' | 'HTTP_RESPONSE_PARSE_ERROR' | 'HTTP_STATUS_ERROR';
|
|
17
47
|
export declare class HttpActionError extends Error {
|
|
18
48
|
readonly code: HttpActionErrorCode;
|
|
@@ -30,7 +60,9 @@ export declare class FetchHttpTransport implements HttpTransport {
|
|
|
30
60
|
}
|
|
31
61
|
export declare class HttpActionExecutor implements ActionExecutor {
|
|
32
62
|
private readonly transport;
|
|
63
|
+
private readonly sessions;
|
|
33
64
|
constructor(transport: HttpTransport);
|
|
34
65
|
supports(action: string): boolean;
|
|
35
66
|
execute(context: ActionExecutionContext): Promise<ActionExecutionResult>;
|
|
67
|
+
private sessionFor;
|
|
36
68
|
}
|
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
/** Internal Runtime-to-HTTP execution key. It is not part of persisted Action data. */
|
|
2
|
+
export const HTTP_EXECUTION_ID_KEY = '__qaick_execution_id';
|
|
3
|
+
/** Cookie state shared by HTTP Actions during one execution. */
|
|
4
|
+
export class HttpCookieJar {
|
|
5
|
+
cookies = new Map();
|
|
6
|
+
store(url, setCookie) {
|
|
7
|
+
if (!setCookie)
|
|
8
|
+
return;
|
|
9
|
+
const parsedUrl = new URL(url);
|
|
10
|
+
for (const header of (Array.isArray(setCookie) ? setCookie : splitSetCookieHeader(setCookie))) {
|
|
11
|
+
const cookie = parseSetCookie(header, parsedUrl);
|
|
12
|
+
if (!cookie)
|
|
13
|
+
continue;
|
|
14
|
+
const key = cookieKey(cookie);
|
|
15
|
+
if (cookie.expiresAt !== undefined && cookie.expiresAt <= Date.now())
|
|
16
|
+
this.cookies.delete(key);
|
|
17
|
+
else
|
|
18
|
+
this.cookies.set(key, cookie);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
header(url) {
|
|
22
|
+
const parsedUrl = new URL(url);
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const applicable = [];
|
|
25
|
+
for (const [key, cookie] of this.cookies) {
|
|
26
|
+
if (cookie.expiresAt !== undefined && cookie.expiresAt <= now) {
|
|
27
|
+
this.cookies.delete(key);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!cookieMatches(cookie, parsedUrl))
|
|
31
|
+
continue;
|
|
32
|
+
applicable.push(cookie);
|
|
33
|
+
}
|
|
34
|
+
applicable.sort((left, right) => right.path.length - left.path.length);
|
|
35
|
+
return applicable.length ? applicable.map(cookie => `${cookie.name}=${cookie.value}`).join('; ') : undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** HTTP-specific session boundary. One instance must be created per QAick execution. */
|
|
39
|
+
export class HttpExecutionSession {
|
|
40
|
+
transport;
|
|
41
|
+
cookies;
|
|
42
|
+
constructor(transport, cookies = new HttpCookieJar()) {
|
|
43
|
+
this.transport = transport;
|
|
44
|
+
this.cookies = cookies;
|
|
45
|
+
}
|
|
46
|
+
async request(url, init) {
|
|
47
|
+
const headers = { ...(init.headers ?? {}) };
|
|
48
|
+
const cookieHeader = headerName(headers, 'cookie');
|
|
49
|
+
const managed = this.cookies.header(url);
|
|
50
|
+
const merged = mergeCookieHeaders(cookieHeader ? headers[cookieHeader] : undefined, managed);
|
|
51
|
+
if (merged)
|
|
52
|
+
headers[cookieHeader ?? 'cookie'] = merged;
|
|
53
|
+
const response = await this.transport.request(url, { ...init, headers });
|
|
54
|
+
const setCookie = response.headers && headerValue(response.headers, 'set-cookie');
|
|
55
|
+
this.cookies.store(url, setCookie);
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
1
59
|
export class HttpActionError extends Error {
|
|
2
60
|
code;
|
|
3
61
|
details;
|
|
@@ -8,6 +66,83 @@ export class HttpActionError extends Error {
|
|
|
8
66
|
this.name = 'HttpActionError';
|
|
9
67
|
}
|
|
10
68
|
}
|
|
69
|
+
function headerName(headers, expected) {
|
|
70
|
+
return Object.keys(headers).find(name => name.toLowerCase() === expected);
|
|
71
|
+
}
|
|
72
|
+
function headerValue(headers, expected) {
|
|
73
|
+
const name = Object.keys(headers).find(key => key.toLowerCase() === expected);
|
|
74
|
+
return name ? headers[name] : undefined;
|
|
75
|
+
}
|
|
76
|
+
function mergeCookieHeaders(explicit, managed) {
|
|
77
|
+
const cookies = new Map();
|
|
78
|
+
for (const value of [managed, explicit]) {
|
|
79
|
+
for (const item of value?.split(';') ?? []) {
|
|
80
|
+
const separator = item.indexOf('=');
|
|
81
|
+
if (separator <= 0)
|
|
82
|
+
continue;
|
|
83
|
+
cookies.set(item.slice(0, separator).trim(), item.slice(separator + 1).trim());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return cookies.size ? [...cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ') : undefined;
|
|
87
|
+
}
|
|
88
|
+
function splitSetCookieHeader(value) {
|
|
89
|
+
return value.split(/\r?\n|,(?=\s*[^;,=\s]+=[^;,]*)/).map(item => item.trim()).filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
function parseSetCookie(header, requestUrl) {
|
|
92
|
+
const parts = header.split(';').map(part => part.trim());
|
|
93
|
+
const pair = parts.shift();
|
|
94
|
+
if (!pair)
|
|
95
|
+
return undefined;
|
|
96
|
+
const separator = pair.indexOf('=');
|
|
97
|
+
if (separator <= 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
const name = pair.slice(0, separator).trim();
|
|
100
|
+
const value = pair.slice(separator + 1).trim();
|
|
101
|
+
let domain = requestUrl.hostname.toLowerCase();
|
|
102
|
+
let hostOnly = true;
|
|
103
|
+
let path = defaultCookiePath(requestUrl.pathname);
|
|
104
|
+
let secure = false;
|
|
105
|
+
let expiresAt;
|
|
106
|
+
for (const attribute of parts) {
|
|
107
|
+
const [rawName, ...rawValue] = attribute.split('=');
|
|
108
|
+
const attributeName = rawName.trim().toLowerCase();
|
|
109
|
+
const attributeValue = rawValue.join('=').trim();
|
|
110
|
+
if (attributeName === 'domain' && attributeValue) {
|
|
111
|
+
domain = attributeValue.replace(/^\./, '').toLowerCase();
|
|
112
|
+
hostOnly = false;
|
|
113
|
+
}
|
|
114
|
+
else if (attributeName === 'path' && attributeValue.startsWith('/'))
|
|
115
|
+
path = attributeValue;
|
|
116
|
+
else if (attributeName === 'secure')
|
|
117
|
+
secure = true;
|
|
118
|
+
else if (attributeName === 'max-age') {
|
|
119
|
+
const seconds = Number(attributeValue);
|
|
120
|
+
if (Number.isFinite(seconds))
|
|
121
|
+
expiresAt = Date.now() + seconds * 1000;
|
|
122
|
+
}
|
|
123
|
+
else if (attributeName === 'expires') {
|
|
124
|
+
const timestamp = Date.parse(attributeValue);
|
|
125
|
+
if (Number.isFinite(timestamp))
|
|
126
|
+
expiresAt = timestamp;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { name, value, domain, path, hostOnly, secure, ...(expiresAt !== undefined ? { expiresAt } : {}) };
|
|
130
|
+
}
|
|
131
|
+
function defaultCookiePath(pathname) {
|
|
132
|
+
if (!pathname || !pathname.startsWith('/'))
|
|
133
|
+
return '/';
|
|
134
|
+
const lastSlash = pathname.lastIndexOf('/');
|
|
135
|
+
return lastSlash <= 0 ? '/' : pathname.slice(0, lastSlash);
|
|
136
|
+
}
|
|
137
|
+
function cookieMatches(cookie, url) {
|
|
138
|
+
if (cookie.secure && url.protocol !== 'https:')
|
|
139
|
+
return false;
|
|
140
|
+
const host = url.hostname.toLowerCase();
|
|
141
|
+
if (cookie.hostOnly ? host !== cookie.domain : !(host === cookie.domain || host.endsWith(`.${cookie.domain}`)))
|
|
142
|
+
return false;
|
|
143
|
+
return url.pathname === cookie.path || url.pathname.startsWith(cookie.path.endsWith('/') ? cookie.path : `${cookie.path}/`);
|
|
144
|
+
}
|
|
145
|
+
function cookieKey(cookie) { return `${cookie.name};${cookie.domain};${cookie.path}`; }
|
|
11
146
|
export class FetchHttpTransport {
|
|
12
147
|
async request(url, init) {
|
|
13
148
|
const headers = { ...init.headers };
|
|
@@ -25,7 +160,7 @@ export class FetchHttpTransport {
|
|
|
25
160
|
const timeout = init.timeoutMs === undefined ? undefined : setTimeout(() => controller.abort(), init.timeoutMs);
|
|
26
161
|
let response;
|
|
27
162
|
try {
|
|
28
|
-
response = await fetch(url, { method: init.method, headers, body, signal: controller.signal });
|
|
163
|
+
response = await fetch(url, { method: init.method, headers, body, signal: controller.signal, credentials: 'include' });
|
|
29
164
|
}
|
|
30
165
|
catch (error) {
|
|
31
166
|
if (init.signal.aborted)
|
|
@@ -54,6 +189,7 @@ export class FetchHttpTransport {
|
|
|
54
189
|
}
|
|
55
190
|
export class HttpActionExecutor {
|
|
56
191
|
transport;
|
|
192
|
+
sessions = new Map();
|
|
57
193
|
constructor(transport) {
|
|
58
194
|
this.transport = transport;
|
|
59
195
|
}
|
|
@@ -76,7 +212,9 @@ export class HttpActionExecutor {
|
|
|
76
212
|
throw new HttpActionError('HTTP_REQUEST_CONSTRUCTION_ERROR', `HTTP Action request could not be constructed. ${detail}`);
|
|
77
213
|
}
|
|
78
214
|
const timeoutMs = typeof context.step.adapterConfig?.timeoutMs === 'number' ? context.step.adapterConfig.timeoutMs : undefined;
|
|
79
|
-
const
|
|
215
|
+
const executionId = typeof context.runtimeValues[HTTP_EXECUTION_ID_KEY] === 'string' ? context.runtimeValues[HTTP_EXECUTION_ID_KEY] : undefined;
|
|
216
|
+
const transport = executionId ? this.sessionFor(executionId) : this.transport;
|
|
217
|
+
const response = await transport.request(request.url, { ...request, signal: context.signal, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
80
218
|
if (response.status < 200 || response.status >= 300) {
|
|
81
219
|
const serializedBody = typeof response.body === 'string' ? response.body : JSON.stringify(response.body) ?? '';
|
|
82
220
|
throw new HttpActionError('HTTP_STATUS_ERROR', `HTTP Action returned status ${response.status}.${serializedBody ? ` Response: ${serializedBody.slice(0, 500)}` : ''}`, { status: response.status });
|
|
@@ -92,6 +230,14 @@ export class HttpActionExecutor {
|
|
|
92
230
|
const finishedAt = new Date().toISOString();
|
|
93
231
|
return { outputs, evidence: { request: { url: request.url, method: request.method, headers: request.headers }, response: { status: response.status, headers: response.headers, body: response.body } }, timing: { startedAt, finishedAt, durationMs: Date.now() - startedMs } };
|
|
94
232
|
}
|
|
233
|
+
sessionFor(executionId) {
|
|
234
|
+
let session = this.sessions.get(executionId);
|
|
235
|
+
if (!session) {
|
|
236
|
+
session = new HttpExecutionSession(this.transport);
|
|
237
|
+
this.sessions.set(executionId, session);
|
|
238
|
+
}
|
|
239
|
+
return session;
|
|
240
|
+
}
|
|
95
241
|
}
|
|
96
242
|
function appendQuery(url, query) {
|
|
97
243
|
if (query === undefined || query === null)
|
package/package.json
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qaick/http-executor",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
4
|
"description": "Reusable QAick HTTP Action execution with browser and native Node transports.",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"qaick",
|
|
7
|
+
"http",
|
|
8
|
+
"executor",
|
|
9
|
+
"automation"
|
|
10
|
+
],
|
|
6
11
|
"type": "module",
|
|
7
|
-
"files": [
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"tag": "alpha"
|
|
22
|
+
},
|
|
10
23
|
"main": "./dist/index.js",
|
|
11
24
|
"types": "./dist/index.d.ts",
|
|
12
25
|
"exports": {
|
|
13
|
-
".": {
|
|
14
|
-
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./node": {
|
|
31
|
+
"types": "./dist/node-http-transport.d.ts",
|
|
32
|
+
"import": "./dist/node-http-transport.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@qaick/executor": ">=0.1.0-alpha.1 <0.2.0"
|
|
15
43
|
},
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@qaick/executor": "0.1.0-alpha.1",
|
|
46
|
+
"@types/node": "^24.13.3",
|
|
47
|
+
"typescript": "~6.0.2",
|
|
48
|
+
"vitest": "^5.0.1"
|
|
49
|
+
}
|
|
19
50
|
}
|