@appium/base-driver 10.2.0 → 10.2.2
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 +201 -0
- package/build/lib/basedriver/capabilities.js +7 -7
- package/build/lib/basedriver/capabilities.js.map +1 -1
- package/build/lib/basedriver/commands/event.d.ts +1 -1
- package/build/lib/basedriver/commands/event.d.ts.map +1 -1
- package/build/lib/basedriver/commands/execute.d.ts +1 -1
- package/build/lib/basedriver/commands/execute.d.ts.map +1 -1
- package/build/lib/basedriver/commands/find.d.ts +1 -1
- package/build/lib/basedriver/commands/find.d.ts.map +1 -1
- package/build/lib/basedriver/commands/mixin.d.ts +1 -1
- package/build/lib/basedriver/commands/mixin.d.ts.map +1 -1
- package/build/lib/basedriver/commands/timeout.d.ts +1 -1
- package/build/lib/basedriver/commands/timeout.d.ts.map +1 -1
- package/build/lib/basedriver/device-settings.d.ts +14 -23
- package/build/lib/basedriver/device-settings.d.ts.map +1 -1
- package/build/lib/basedriver/device-settings.js +11 -26
- package/build/lib/basedriver/device-settings.js.map +1 -1
- package/build/lib/basedriver/helpers.d.ts +36 -57
- package/build/lib/basedriver/helpers.d.ts.map +1 -1
- package/build/lib/basedriver/helpers.js +148 -239
- package/build/lib/basedriver/helpers.js.map +1 -1
- package/build/lib/basedriver/logger.d.ts +1 -2
- package/build/lib/basedriver/logger.d.ts.map +1 -1
- package/build/lib/basedriver/logger.js +2 -2
- package/build/lib/basedriver/logger.js.map +1 -1
- package/build/lib/basedriver/validation.d.ts.map +1 -1
- package/build/lib/basedriver/validation.js +3 -3
- package/build/lib/basedriver/validation.js.map +1 -1
- package/build/lib/constants.d.ts +1 -1
- package/build/lib/constants.d.ts.map +1 -1
- package/build/lib/express/crash.d.ts +8 -2
- package/build/lib/express/crash.d.ts.map +1 -1
- package/build/lib/express/crash.js +6 -0
- package/build/lib/express/crash.js.map +1 -1
- package/build/lib/express/express-logging.d.ts +12 -2
- package/build/lib/express/express-logging.d.ts.map +1 -1
- package/build/lib/express/express-logging.js +34 -26
- package/build/lib/express/express-logging.js.map +1 -1
- package/build/lib/express/idempotency.d.ts +4 -10
- package/build/lib/express/idempotency.d.ts.map +1 -1
- package/build/lib/express/idempotency.js +69 -73
- package/build/lib/express/idempotency.js.map +1 -1
- package/build/lib/express/logger.d.ts +1 -2
- package/build/lib/express/logger.d.ts.map +1 -1
- package/build/lib/express/logger.js +2 -2
- package/build/lib/express/logger.js.map +1 -1
- package/build/lib/express/middleware.d.ts +37 -41
- package/build/lib/express/middleware.d.ts.map +1 -1
- package/build/lib/express/middleware.js +48 -60
- package/build/lib/express/middleware.js.map +1 -1
- package/build/lib/express/server.d.ts +57 -101
- package/build/lib/express/server.d.ts.map +1 -1
- package/build/lib/express/server.js +51 -128
- package/build/lib/express/server.js.map +1 -1
- package/build/lib/express/static.d.ts +10 -5
- package/build/lib/express/static.d.ts.map +1 -1
- package/build/lib/express/static.js +32 -42
- package/build/lib/express/static.js.map +1 -1
- package/build/lib/express/websocket.d.ts +22 -6
- package/build/lib/express/websocket.d.ts.map +1 -1
- package/build/lib/express/websocket.js +10 -15
- package/build/lib/express/websocket.js.map +1 -1
- package/build/lib/helpers/capabilities.d.ts +4 -16
- package/build/lib/helpers/capabilities.d.ts.map +1 -1
- package/build/lib/helpers/capabilities.js +36 -48
- package/build/lib/helpers/capabilities.js.map +1 -1
- package/build/lib/jsonwp-proxy/protocol-converter.d.ts +42 -78
- package/build/lib/jsonwp-proxy/protocol-converter.d.ts.map +1 -1
- package/build/lib/jsonwp-proxy/protocol-converter.js +87 -139
- package/build/lib/jsonwp-proxy/protocol-converter.js.map +1 -1
- package/build/lib/jsonwp-proxy/proxy.d.ts +1 -1
- package/build/lib/jsonwp-proxy/proxy.d.ts.map +1 -1
- package/build/lib/jsonwp-proxy/proxy.js +2 -2
- package/build/lib/jsonwp-proxy/proxy.js.map +1 -1
- package/build/lib/jsonwp-status/status.d.ts +113 -158
- package/build/lib/jsonwp-status/status.d.ts.map +1 -1
- package/build/lib/jsonwp-status/status.js +10 -14
- package/build/lib/jsonwp-status/status.js.map +1 -1
- package/build/lib/protocol/bidi-commands.d.ts +31 -36
- package/build/lib/protocol/bidi-commands.d.ts.map +1 -1
- package/build/lib/protocol/bidi-commands.js +5 -5
- package/build/lib/protocol/bidi-commands.js.map +1 -1
- package/build/lib/protocol/errors.d.ts.map +1 -1
- package/build/lib/protocol/helpers.d.ts +7 -11
- package/build/lib/protocol/helpers.d.ts.map +1 -1
- package/build/lib/protocol/helpers.js +5 -9
- package/build/lib/protocol/helpers.js.map +1 -1
- package/build/lib/protocol/index.d.ts +4 -21
- package/build/lib/protocol/index.d.ts.map +1 -1
- package/build/lib/protocol/index.js.map +1 -1
- package/build/lib/protocol/protocol.d.ts +15 -1
- package/build/lib/protocol/protocol.d.ts.map +1 -1
- package/build/lib/protocol/protocol.js +50 -20
- package/build/lib/protocol/protocol.js.map +1 -1
- package/build/lib/protocol/routes.d.ts +8 -15
- package/build/lib/protocol/routes.d.ts.map +1 -1
- package/build/lib/protocol/routes.js +18 -33
- package/build/lib/protocol/routes.js.map +1 -1
- package/lib/basedriver/capabilities.ts +1 -1
- package/lib/basedriver/commands/event.ts +2 -2
- package/lib/basedriver/commands/execute.ts +2 -2
- package/lib/basedriver/commands/find.ts +2 -2
- package/lib/basedriver/commands/mixin.ts +1 -1
- package/lib/basedriver/commands/timeout.ts +2 -2
- package/lib/basedriver/{device-settings.js → device-settings.ts} +24 -35
- package/lib/basedriver/{helpers.js → helpers.ts} +208 -266
- package/lib/basedriver/logger.ts +3 -0
- package/lib/basedriver/validation.ts +2 -2
- package/lib/constants.ts +1 -1
- package/lib/express/crash.ts +15 -0
- package/lib/express/express-logging.ts +84 -0
- package/lib/express/{idempotency.js → idempotency.ts} +105 -89
- package/lib/express/logger.ts +3 -0
- package/lib/express/middleware.ts +187 -0
- package/lib/express/{server.js → server.ts} +175 -167
- package/lib/express/static.ts +77 -0
- package/lib/express/websocket.ts +81 -0
- package/lib/helpers/capabilities.ts +83 -0
- package/lib/jsonwp-proxy/protocol-converter.ts +284 -0
- package/lib/jsonwp-proxy/proxy.js +1 -1
- package/lib/jsonwp-status/{status.js → status.ts} +12 -15
- package/lib/protocol/{bidi-commands.js → bidi-commands.ts} +7 -5
- package/lib/protocol/errors.ts +1 -1
- package/lib/protocol/{helpers.js → helpers.ts} +8 -11
- package/lib/protocol/protocol.ts +57 -26
- package/lib/protocol/{routes.js → routes.ts} +29 -40
- package/package.json +11 -11
- package/tsconfig.json +3 -1
- package/lib/basedriver/logger.js +0 -4
- package/lib/express/crash.js +0 -11
- package/lib/express/express-logging.js +0 -60
- package/lib/express/logger.js +0 -4
- package/lib/express/middleware.js +0 -171
- package/lib/express/static.js +0 -76
- package/lib/express/websocket.js +0 -79
- package/lib/helpers/capabilities.js +0 -93
- package/lib/jsonwp-proxy/protocol-converter.js +0 -317
- /package/lib/protocol/{index.js → index.ts} +0 -0
package/lib/constants.ts
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {errors} from '../protocol';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Route handler that throws {@link errors.UnknownCommandError} for testing.
|
|
5
|
+
*/
|
|
6
|
+
export function produceError(): never {
|
|
7
|
+
throw new errors.UnknownCommandError('Produced generic error for testing');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Route handler that throws a generic Error for testing (e.g. crash scenarios).
|
|
12
|
+
*/
|
|
13
|
+
export function produceCrash(): never {
|
|
14
|
+
throw new Error('We just tried to crash Appium!');
|
|
15
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
import '@colors/colors';
|
|
3
|
+
import morgan from 'morgan';
|
|
4
|
+
import type {Request, RequestHandler, Response} from 'express';
|
|
5
|
+
import {log} from './logger';
|
|
6
|
+
import {MAX_LOG_BODY_LENGTH} from '../constants';
|
|
7
|
+
import {logger} from '@appium/support';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Morgan middleware that logs when the HTTP response finishes.
|
|
11
|
+
* Logs method, URL, status (color-coded), response time, and content-length.
|
|
12
|
+
*/
|
|
13
|
+
export const endLogFormatter: RequestHandler = morgan(endLogFormatterHandler);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Morgan middleware that logs when the HTTP request is received (immediate).
|
|
17
|
+
* Logs method and URL; request body is truncated and passed through {@link logger.markSensitive}.
|
|
18
|
+
*/
|
|
19
|
+
export const startLogFormatter: RequestHandler = morgan(startLogFormatterHandler, {
|
|
20
|
+
immediate: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// #region Private types and helpers
|
|
24
|
+
type MorganTokens = unknown;
|
|
25
|
+
type FormatFn = (tokens: MorganTokens, req: Request, res: Response) => string;
|
|
26
|
+
|
|
27
|
+
function endLogFormatterHandler(tokens: MorganTokens, req: Request, res: Response): void {
|
|
28
|
+
log.info(requestEndLoggingFormat(tokens, req, res));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function startLogFormatterHandler(tokens: unknown, req: Request, res: Response): void {
|
|
32
|
+
let reqBody = '';
|
|
33
|
+
if (req.body) {
|
|
34
|
+
try {
|
|
35
|
+
reqBody = _.truncate(
|
|
36
|
+
_.isString(req.body) ? req.body : JSON.stringify(req.body),
|
|
37
|
+
{length: MAX_LOG_BODY_LENGTH}
|
|
38
|
+
);
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
log.info(
|
|
44
|
+
requestStartLoggingFormat(tokens, req, res),
|
|
45
|
+
logger.markSensitive(reqBody.grey)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Copied the morgan compile function over so that cooler formats may be configured
|
|
50
|
+
function compile(fmt: string): FormatFn {
|
|
51
|
+
fmt = fmt.replace(/"/g, '\\"');
|
|
52
|
+
fmt = fmt.replace(/:([-\w]{2,})(?:\[([^\]]+)\])?/g, function replace(_, name, arg) {
|
|
53
|
+
return `"\n + (tokens["${name}"](req, res, "${arg}") || "-") + "`;
|
|
54
|
+
});
|
|
55
|
+
const js = ` return "${fmt}";`;
|
|
56
|
+
return new Function('tokens', 'req', 'res', js) as FormatFn;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requestEndLoggingFormat(
|
|
60
|
+
tokens: MorganTokens,
|
|
61
|
+
req: Request,
|
|
62
|
+
res: Response
|
|
63
|
+
): string {
|
|
64
|
+
const status = res.statusCode;
|
|
65
|
+
let statusStr = ':status';
|
|
66
|
+
if (status >= 500) {
|
|
67
|
+
statusStr = statusStr.red;
|
|
68
|
+
} else if (status >= 400) {
|
|
69
|
+
statusStr = statusStr.yellow;
|
|
70
|
+
} else if (status >= 300) {
|
|
71
|
+
statusStr = statusStr.cyan;
|
|
72
|
+
} else {
|
|
73
|
+
statusStr = statusStr.green;
|
|
74
|
+
}
|
|
75
|
+
const fn = compile(
|
|
76
|
+
`${'<-- :method :url '.white}${statusStr} ${':response-time ms - :res[content-length]'.grey}`
|
|
77
|
+
);
|
|
78
|
+
return fn(tokens, req, res);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const requestStartLoggingFormat = compile(
|
|
82
|
+
`${'-->'.white} ${':method'.white} ${':url'.white}`
|
|
83
|
+
);
|
|
84
|
+
// #endregion
|
|
@@ -1,37 +1,99 @@
|
|
|
1
|
-
import log from './logger';
|
|
2
|
-
import {
|
|
1
|
+
import {log} from './logger';
|
|
2
|
+
import {LRUCache} from 'lru-cache';
|
|
3
3
|
import _ from 'lodash';
|
|
4
4
|
import {EventEmitter} from 'node:events';
|
|
5
|
+
import type {Request, Response, NextFunction} from 'express';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
interface CachedResponse {
|
|
8
|
+
method: string;
|
|
9
|
+
path: string;
|
|
10
|
+
response: Buffer | null;
|
|
11
|
+
responseStateListener: EventEmitter | null | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const IDEMPOTENT_RESPONSES = new LRUCache<string, CachedResponse>({
|
|
7
15
|
max: 64,
|
|
8
16
|
ttl: 30 * 60 * 1000,
|
|
9
17
|
updateAgeOnGet: true,
|
|
10
18
|
updateAgeOnHas: true,
|
|
11
|
-
// @ts-ignore The value must contain responseStateListener
|
|
12
19
|
dispose: ({responseStateListener}) => {
|
|
13
20
|
responseStateListener?.removeAllListeners();
|
|
14
|
-
}
|
|
21
|
+
},
|
|
15
22
|
});
|
|
16
23
|
const MONITORED_METHODS = ['POST', 'PATCH'];
|
|
17
24
|
const IDEMPOTENCY_KEY_HEADER = 'x-idempotency-key';
|
|
18
25
|
const MAX_CACHED_PAYLOAD_SIZE_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* @property {string} path
|
|
24
|
-
* @property {Buffer?} response
|
|
25
|
-
* @property {EventEmitter|null|undefined} responseStateListener
|
|
28
|
+
* Middleware that caches and replays responses for idempotent requests using the
|
|
29
|
+
* `x-idempotency-key` header. Only POST and PATCH are cached.
|
|
26
30
|
*/
|
|
31
|
+
export async function handleIdempotency(
|
|
32
|
+
req: Request,
|
|
33
|
+
res: Response,
|
|
34
|
+
next: NextFunction
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const keyOrArr = req.headers[IDEMPOTENCY_KEY_HEADER];
|
|
37
|
+
if (_.isEmpty(keyOrArr) || !keyOrArr) {
|
|
38
|
+
next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
const key = _.isArray(keyOrArr) ? keyOrArr[0] : keyOrArr;
|
|
43
|
+
|
|
44
|
+
log.updateAsyncContext({idempotencyKey: key});
|
|
45
|
+
|
|
46
|
+
if (!MONITORED_METHODS.includes(req.method)) {
|
|
47
|
+
next();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
log.debug(`Request idempotency key: ${key}`);
|
|
52
|
+
if (!IDEMPOTENT_RESPONSES.has(key)) {
|
|
53
|
+
cacheResponse(key, req, res);
|
|
54
|
+
next();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cached = IDEMPOTENT_RESPONSES.get(key);
|
|
59
|
+
if (!cached) {
|
|
60
|
+
next();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const {method, path, response, responseStateListener} = cached;
|
|
64
|
+
if (req.method !== method || req.path !== path) {
|
|
65
|
+
log.warn(`Got two different requests with the same idempotency key '${key}'`);
|
|
66
|
+
log.warn('Is the client generating idempotency keys properly?');
|
|
67
|
+
next();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (response) {
|
|
72
|
+
log.info(`The same request with the idempotency key '${key}' has been already processed`);
|
|
73
|
+
log.info(`Rerouting its response to the current request`);
|
|
74
|
+
if (!res.socket?.writable) {
|
|
75
|
+
next();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
res.socket.write(response.toString('utf8'));
|
|
79
|
+
} else {
|
|
80
|
+
log.info(`The same request with the idempotency key '${key}' is being processed`);
|
|
81
|
+
log.info(`Waiting for the response to be rerouted to the current request`);
|
|
82
|
+
if (!responseStateListener) {
|
|
83
|
+
next();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
responseStateListener.once('ready', (cachedResponse: Buffer | null) => {
|
|
87
|
+
if (!cachedResponse || !res.socket?.writable) {
|
|
88
|
+
next();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
res.socket.write(cachedResponse.toString('utf8'));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cacheResponse(key: string, req: Request, res: Response): void {
|
|
35
97
|
if (!res.socket) {
|
|
36
98
|
return;
|
|
37
99
|
}
|
|
@@ -46,28 +108,43 @@ function cacheResponse(key, req, res) {
|
|
|
46
108
|
const socket = res.socket;
|
|
47
109
|
const originalSocketWriter = socket.write.bind(socket);
|
|
48
110
|
const responseRef = new WeakRef(res);
|
|
49
|
-
let responseChunks = [];
|
|
111
|
+
let responseChunks: Buffer[] = [];
|
|
50
112
|
let responseSize = 0;
|
|
51
|
-
let errorMessage = null;
|
|
52
|
-
const patchedWriter = (
|
|
113
|
+
let errorMessage: string | null = null;
|
|
114
|
+
const patchedWriter = (
|
|
115
|
+
chunk: unknown,
|
|
116
|
+
encoding: BufferEncoding | (() => void),
|
|
117
|
+
next?: (() => void) | ((err?: Error) => void)
|
|
118
|
+
): boolean => {
|
|
53
119
|
if (errorMessage || !responseRef.deref()) {
|
|
54
120
|
responseChunks = [];
|
|
55
121
|
responseSize = 0;
|
|
56
|
-
return originalSocketWriter(
|
|
122
|
+
return originalSocketWriter(
|
|
123
|
+
chunk as string | Buffer | Uint8Array,
|
|
124
|
+
encoding as BufferEncoding,
|
|
125
|
+
next as (err?: Error) => void
|
|
126
|
+
);
|
|
57
127
|
}
|
|
58
128
|
|
|
59
|
-
const buf = Buffer.
|
|
129
|
+
const buf = Buffer.isBuffer(chunk)
|
|
130
|
+
? chunk
|
|
131
|
+
: Buffer.from(chunk as string, typeof encoding === 'string' ? encoding : undefined);
|
|
60
132
|
responseChunks.push(buf);
|
|
61
133
|
responseSize += buf.length;
|
|
62
134
|
if (responseSize > MAX_CACHED_PAYLOAD_SIZE_BYTES) {
|
|
63
|
-
errorMessage =
|
|
135
|
+
errorMessage =
|
|
136
|
+
`The actual response size exceeds ` +
|
|
64
137
|
`the maximum allowed limit of ${MAX_CACHED_PAYLOAD_SIZE_BYTES} bytes`;
|
|
65
138
|
}
|
|
66
|
-
return originalSocketWriter(
|
|
139
|
+
return originalSocketWriter(
|
|
140
|
+
chunk as string | Buffer | Uint8Array,
|
|
141
|
+
encoding as BufferEncoding,
|
|
142
|
+
next as (err?: Error) => void
|
|
143
|
+
);
|
|
67
144
|
};
|
|
68
|
-
socket.write = patchedWriter;
|
|
145
|
+
socket.write = patchedWriter as typeof socket.write;
|
|
69
146
|
let didEmitReady = false;
|
|
70
|
-
res.once('error', (e) => {
|
|
147
|
+
res.once('error', (e: Error) => {
|
|
71
148
|
errorMessage = e.message;
|
|
72
149
|
if (socket.write === patchedWriter) {
|
|
73
150
|
socket.write = originalSocketWriter;
|
|
@@ -76,7 +153,7 @@ function cacheResponse(key, req, res) {
|
|
|
76
153
|
if (!IDEMPOTENT_RESPONSES.has(key)) {
|
|
77
154
|
log.info(
|
|
78
155
|
`Could not cache the response identified by '${key}'. ` +
|
|
79
|
-
|
|
156
|
+
`Cache consistency has been damaged`
|
|
80
157
|
);
|
|
81
158
|
} else {
|
|
82
159
|
log.info(`Could not cache the response identified by '${key}': ${errorMessage}`);
|
|
@@ -98,15 +175,13 @@ function cacheResponse(key, req, res) {
|
|
|
98
175
|
if (!IDEMPOTENT_RESPONSES.has(key)) {
|
|
99
176
|
log.info(
|
|
100
177
|
`Could not cache the response identified by '${key}'. ` +
|
|
101
|
-
|
|
178
|
+
`Cache consistency has been damaged`
|
|
102
179
|
);
|
|
103
180
|
} else if (errorMessage) {
|
|
104
181
|
log.info(`Could not cache the response identified by '${key}': ${errorMessage}`);
|
|
105
182
|
IDEMPOTENT_RESPONSES.delete(key);
|
|
106
183
|
}
|
|
107
184
|
|
|
108
|
-
/** @type {CachedResponse|undefined} */
|
|
109
|
-
// @ts-ignore The returned type is ok
|
|
110
185
|
const value = IDEMPOTENT_RESPONSES.get(key);
|
|
111
186
|
if (value) {
|
|
112
187
|
value.response = Buffer.concat(responseChunks);
|
|
@@ -124,68 +199,9 @@ function cacheResponse(key, req, res) {
|
|
|
124
199
|
}
|
|
125
200
|
|
|
126
201
|
if (!didEmitReady) {
|
|
127
|
-
/** @type {CachedResponse|undefined} */
|
|
128
|
-
// @ts-ignore The returned type is ok
|
|
129
202
|
const value = IDEMPOTENT_RESPONSES.get(key);
|
|
130
203
|
responseStateListener.emit('ready', value?.response ?? null);
|
|
131
204
|
didEmitReady = true;
|
|
132
205
|
}
|
|
133
206
|
});
|
|
134
207
|
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* @param {import('express').Request} req
|
|
138
|
-
* @param {import('express').Response} res
|
|
139
|
-
*/
|
|
140
|
-
async function handleIdempotency(req, res, next) {
|
|
141
|
-
const keyOrArr = req.headers[IDEMPOTENCY_KEY_HEADER];
|
|
142
|
-
if (_.isEmpty(keyOrArr) || !keyOrArr) {
|
|
143
|
-
return next();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const key = _.isArray(keyOrArr) ? keyOrArr[0] : keyOrArr;
|
|
147
|
-
|
|
148
|
-
log.updateAsyncContext({idempotencyKey: key});
|
|
149
|
-
|
|
150
|
-
if (!MONITORED_METHODS.includes(req.method)) {
|
|
151
|
-
// GET, DELETE, etc. requests are idempotent by default
|
|
152
|
-
// there is no need to cache them
|
|
153
|
-
return next();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
log.debug(`Request idempotency key: ${key}`);
|
|
157
|
-
if (!IDEMPOTENT_RESPONSES.has(key)) {
|
|
158
|
-
cacheResponse(key, req, res);
|
|
159
|
-
return next();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const {
|
|
163
|
-
// @ts-ignore We have asserted the presence of the key above
|
|
164
|
-
method, path, response, responseStateListener,
|
|
165
|
-
} = IDEMPOTENT_RESPONSES.get(key);
|
|
166
|
-
if (req.method !== method || req.path !== path) {
|
|
167
|
-
log.warn(`Got two different requests with the same idempotency key '${key}'`);
|
|
168
|
-
log.warn('Is the client generating idempotency keys properly?');
|
|
169
|
-
return next();
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (response) {
|
|
173
|
-
log.info(`The same request with the idempotency key '${key}' has been already processed`);
|
|
174
|
-
log.info(`Rerouting its response to the current request`);
|
|
175
|
-
if (!res.socket?.writable) {
|
|
176
|
-
return next();
|
|
177
|
-
}
|
|
178
|
-
res.socket.write(response.toString('utf8'));
|
|
179
|
-
} else {
|
|
180
|
-
log.info(`The same request with the idempotency key '${key}' is being processed`);
|
|
181
|
-
log.info(`Waiting for the response to be rerouted to the current request`);
|
|
182
|
-
responseStateListener.once('ready', async (/** @type {Buffer?} */ cachedResponse) => {
|
|
183
|
-
if (!cachedResponse || !res.socket?.writable) {
|
|
184
|
-
return next();
|
|
185
|
-
}
|
|
186
|
-
res.socket.write(cachedResponse.toString('utf8'));
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export {handleIdempotency};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
import type {NextFunction, Request, RequestHandler, Response} from 'express';
|
|
3
|
+
import type {IncomingMessage} from 'node:http';
|
|
4
|
+
import type {Duplex} from 'node:stream';
|
|
5
|
+
import {log} from './logger';
|
|
6
|
+
import {errors} from '../protocol';
|
|
7
|
+
export {handleIdempotency} from './idempotency';
|
|
8
|
+
import {match} from 'path-to-regexp';
|
|
9
|
+
import {util} from '@appium/support';
|
|
10
|
+
import {calcSignature} from '../helpers/session';
|
|
11
|
+
import {getResponseForW3CError} from '../protocol/errors';
|
|
12
|
+
import type {StringRecord, WSServer} from '@appium/types';
|
|
13
|
+
|
|
14
|
+
const SESSION_ID_PATTERN = /\/session\/([^/]+)/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Basic CORS middleware.
|
|
18
|
+
* Sets permissive CORS headers and responds immediately to `OPTIONS` requests with 200.
|
|
19
|
+
*/
|
|
20
|
+
export function allowCrossDomain(req: Request, res: Response, next: NextFunction): void {
|
|
21
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
22
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS, DELETE');
|
|
23
|
+
res.header(
|
|
24
|
+
'Access-Control-Allow-Headers',
|
|
25
|
+
'Cache-Control, Pragma, Origin, X-Requested-With, Content-Type, Accept, User-Agent'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (req.method === 'OPTIONS') {
|
|
29
|
+
res.sendStatus(200);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
next();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CORS middleware for async execute response endpoints only.
|
|
37
|
+
* Leaves other routes untouched but applies {@link allowCrossDomain} to async response URLs.
|
|
38
|
+
*
|
|
39
|
+
* @param basePath - Server base path (e.g. `/wd/hub` or `/`)
|
|
40
|
+
* @returns Express request handler
|
|
41
|
+
*/
|
|
42
|
+
export function allowCrossDomainAsyncExecute(basePath: string): RequestHandler {
|
|
43
|
+
function allowCrossDomainAsyncExecuteHandler(
|
|
44
|
+
req: Request,
|
|
45
|
+
res: Response,
|
|
46
|
+
next: NextFunction
|
|
47
|
+
): void {
|
|
48
|
+
const receiveAsyncResponseRegExp = new RegExp(
|
|
49
|
+
`${_.escapeRegExp(basePath)}/session/[a-f0-9-]+/(appium/)?receive_async_response`
|
|
50
|
+
);
|
|
51
|
+
if (!receiveAsyncResponseRegExp.test(req.url)) {
|
|
52
|
+
next();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
allowCrossDomain(req, res, next);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return allowCrossDomainAsyncExecuteHandler;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Populates the logger's async context with request and session metadata.
|
|
63
|
+
* Derives `requestId`, optional session id/signature, and `isSensitive` flag from headers/URL.
|
|
64
|
+
*/
|
|
65
|
+
export function handleLogContext(req: Request, _res: Response, next: NextFunction): void {
|
|
66
|
+
const requestId = fetchHeaderValue(req, 'x-request-id') || util.uuidV4();
|
|
67
|
+
|
|
68
|
+
const sessionId = SESSION_ID_PATTERN.exec(req.url)?.[1];
|
|
69
|
+
const sessionInfo = sessionId ? {sessionId, sessionSignature: calcSignature(sessionId)} : {};
|
|
70
|
+
const isSensitiveHeaderValue = fetchHeaderValue(req, 'x-appium-is-sensitive');
|
|
71
|
+
|
|
72
|
+
log.updateAsyncContext(
|
|
73
|
+
{
|
|
74
|
+
requestId,
|
|
75
|
+
...sessionInfo,
|
|
76
|
+
isSensitive: ['true', '1', 'yes'].includes(_.toLower(isSensitiveHeaderValue)),
|
|
77
|
+
},
|
|
78
|
+
true
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
next();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ensures requests default to JSON content-type when none is provided.
|
|
86
|
+
*/
|
|
87
|
+
export function defaultToJSONContentType(
|
|
88
|
+
req: Request,
|
|
89
|
+
_res: Response,
|
|
90
|
+
next: NextFunction
|
|
91
|
+
): void {
|
|
92
|
+
if (!req.headers['content-type']) {
|
|
93
|
+
req.headers['content-type'] = 'application/json; charset=utf-8';
|
|
94
|
+
}
|
|
95
|
+
next();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Attempts to handle a WebSocket upgrade by matching the request path against registered handlers.
|
|
100
|
+
*
|
|
101
|
+
* @param req - Incoming HTTP request
|
|
102
|
+
* @param socket - Network socket
|
|
103
|
+
* @param head - First packet of the upgraded stream
|
|
104
|
+
* @param webSocketsMapping - Path-to-WebSocket-server mapping
|
|
105
|
+
* @returns `true` if the upgrade was handled; `false` otherwise
|
|
106
|
+
*/
|
|
107
|
+
export function tryHandleWebSocketUpgrade(
|
|
108
|
+
req: IncomingMessage,
|
|
109
|
+
socket: Duplex,
|
|
110
|
+
head: Buffer,
|
|
111
|
+
webSocketsMapping: StringRecord<WSServer>
|
|
112
|
+
): boolean {
|
|
113
|
+
if (_.toLower(req.headers?.upgrade) !== 'websocket') {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let currentPathname: string;
|
|
118
|
+
try {
|
|
119
|
+
currentPathname = new URL(req.url ?? '', 'http://localhost').pathname;
|
|
120
|
+
} catch {
|
|
121
|
+
currentPathname = req.url ?? '';
|
|
122
|
+
}
|
|
123
|
+
for (const [pathname, wsServer] of _.toPairs(webSocketsMapping)) {
|
|
124
|
+
if (match(pathname)(currentPathname)) {
|
|
125
|
+
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
126
|
+
wsServer.emit('connection', ws, req);
|
|
127
|
+
});
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
log.info(`Did not match the websocket upgrade request at ${currentPathname} to any known route`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Express middleware wrapper around {@link tryHandleWebSocketUpgrade}.
|
|
137
|
+
* Delegates WebSocket upgrades to the mapping and falls through to `next()` otherwise.
|
|
138
|
+
*
|
|
139
|
+
* @param webSocketsMapping - Path-to-WebSocket-server mapping
|
|
140
|
+
* @returns Express request handler
|
|
141
|
+
*/
|
|
142
|
+
export function handleUpgrade(webSocketsMapping: StringRecord<WSServer>): RequestHandler {
|
|
143
|
+
function handleUpgradeMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
144
|
+
if (tryHandleWebSocketUpgrade(req, req.socket, Buffer.from(''), webSocketsMapping)) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
next();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return handleUpgradeMiddleware;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Final error-handling middleware.
|
|
155
|
+
* Logs uncaught errors and returns a W3C-formatted error response unless headers were already sent.
|
|
156
|
+
*/
|
|
157
|
+
export function catchAllHandler(
|
|
158
|
+
err: Error,
|
|
159
|
+
_req: Request,
|
|
160
|
+
res: Response,
|
|
161
|
+
next: NextFunction
|
|
162
|
+
): void {
|
|
163
|
+
if (res.headersSent) {
|
|
164
|
+
next(err);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
log.error(`Uncaught error: ${err.message}`);
|
|
169
|
+
const [status, body] = getResponseForW3CError(err);
|
|
170
|
+
res.status(status).json(body);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 404 handler for unmatched routes.
|
|
175
|
+
* Logs a debug message and responds with `UnknownCommandError` in W3C format.
|
|
176
|
+
*/
|
|
177
|
+
export function catch404Handler(req: Request, res: Response): void {
|
|
178
|
+
log.debug(`No route found for ${req.url}`);
|
|
179
|
+
const [status, body] = getResponseForW3CError(new errors.UnknownCommandError());
|
|
180
|
+
res.status(status).json(body);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function fetchHeaderValue(req: Request, name: string): string | undefined {
|
|
184
|
+
const value = req.headers[name];
|
|
185
|
+
return _.isArray(value) ? value[0] : (value as string | undefined);
|
|
186
|
+
}
|
|
187
|
+
|