peakflow-api 0.0.7 → 0.0.8
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/CHANGELOG.md +6 -0
- package/README.md +13 -0
- package/package.json +1 -1
- package/spec/bug-reporting.spec.js +1 -0
- package/spec/requests/fetch-request.spec.js +35 -0
- package/spec/requests/node-request.spec.js +61 -0
- package/spec/requests/xhr-request.spec.js +55 -0
- package/src/bug-reporting/index.js +44 -91
- package/src/bug-reporting/requests/fetch-request.js +21 -0
- package/src/bug-reporting/requests/node-request.js +62 -0
- package/src/bug-reporting/requests/xhr-request.js +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,3 +3,9 @@
|
|
|
3
3
|
- Chore: keep single-line JSDoc comments on a single line and document the convention.
|
|
4
4
|
- Fix: defer runtime import helper creation to avoid CSP unsafe-eval during browser module load.
|
|
5
5
|
- Fix: build Node module specifiers dynamically to avoid Expo bundler resolution errors.
|
|
6
|
+
- Change: extract request transports into classes with selectable RequestClass fallback.
|
|
7
|
+
- Change: validate BugReporting constructor options and reject unknown keys.
|
|
8
|
+
- Change: validate RequestClass immediately in BugReporting constructor.
|
|
9
|
+
- Test: cover XHR, fetch, and Node request transports.
|
|
10
|
+
- Change: allow NodeRequest to accept a runtime import override for testing.
|
|
11
|
+
- Chore: split request transport specs into dedicated files.
|
package/README.md
CHANGED
|
@@ -17,6 +17,19 @@ const bugReporting = new BugReporting({authToken: "your-token"})
|
|
|
17
17
|
bugReporting.connect()
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## Request transport
|
|
21
|
+
|
|
22
|
+
By default, BugReporting uses `XMLHttpRequest` when available, otherwise it falls back to `fetch`. If neither is available, you must provide a request class.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import {BugReporting, NodeRequest} from "peakflow"
|
|
26
|
+
|
|
27
|
+
const bugReporting = new BugReporting({
|
|
28
|
+
authToken: "your-token",
|
|
29
|
+
RequestClass: NodeRequest
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
20
33
|
## Browser error listeners
|
|
21
34
|
|
|
22
35
|
```js
|
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import FetchRequest from "../../src/bug-reporting/requests/fetch-request.js"
|
|
2
|
+
|
|
3
|
+
describe("FetchRequest", () => {
|
|
4
|
+
const originalGlobals = {
|
|
5
|
+
fetch: globalThis.fetch
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
globalThis.fetch = originalGlobals.fetch
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("sends requests with fetch", async () => {
|
|
13
|
+
let receivedUrl = null
|
|
14
|
+
let receivedOptions = null
|
|
15
|
+
|
|
16
|
+
globalThis.fetch = async (url, options) => {
|
|
17
|
+
receivedUrl = url
|
|
18
|
+
receivedOptions = options
|
|
19
|
+
return {
|
|
20
|
+
text: async () => "ok"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const request = new FetchRequest()
|
|
25
|
+
const responseText = await request.send("https://example.test/bugs", {hello: "world"})
|
|
26
|
+
|
|
27
|
+
expect(responseText).toBe("ok")
|
|
28
|
+
expect(receivedUrl).toBe("https://example.test/bugs")
|
|
29
|
+
expect(receivedOptions).toEqual({
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {"Content-Type": "application/json"},
|
|
32
|
+
body: JSON.stringify({hello: "world"})
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import NodeRequest from "../../src/bug-reporting/requests/node-request.js"
|
|
2
|
+
|
|
3
|
+
describe("NodeRequest", () => {
|
|
4
|
+
it("sends requests with the Node transport", async () => {
|
|
5
|
+
let requestOptions = null
|
|
6
|
+
let requestBody = null
|
|
7
|
+
|
|
8
|
+
const fakeRequest = (options, callback) => {
|
|
9
|
+
requestOptions = options
|
|
10
|
+
const res = {
|
|
11
|
+
setEncoding: () => {},
|
|
12
|
+
on: (event, handler) => {
|
|
13
|
+
if (event === "data") {
|
|
14
|
+
res.onData = handler
|
|
15
|
+
}
|
|
16
|
+
if (event === "end") {
|
|
17
|
+
res.onEnd = handler
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
callback(res)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
on: () => {},
|
|
26
|
+
write: (body) => {
|
|
27
|
+
requestBody = body
|
|
28
|
+
},
|
|
29
|
+
end: () => {
|
|
30
|
+
if (res.onData) {
|
|
31
|
+
res.onData("node-ok")
|
|
32
|
+
}
|
|
33
|
+
if (res.onEnd) {
|
|
34
|
+
res.onEnd()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const request = new NodeRequest({
|
|
41
|
+
runtimeImport: (specifier) => {
|
|
42
|
+
if (specifier === ["node", "https"].join(":")) {
|
|
43
|
+
return Promise.resolve({request: fakeRequest})
|
|
44
|
+
}
|
|
45
|
+
if (specifier === ["node", "url"].join(":")) {
|
|
46
|
+
return Promise.resolve({URL: globalThis.URL})
|
|
47
|
+
}
|
|
48
|
+
return Promise.reject(new Error(`Unexpected specifier: ${specifier}`))
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const responseText = await request.send("https://example.test/path?q=1", {hello: "world"})
|
|
53
|
+
|
|
54
|
+
expect(responseText).toBe("node-ok")
|
|
55
|
+
expect(requestOptions).toEqual(jasmine.objectContaining({
|
|
56
|
+
method: "POST",
|
|
57
|
+
path: "/path?q=1"
|
|
58
|
+
}))
|
|
59
|
+
expect(requestBody).toBe(JSON.stringify({hello: "world"}))
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import XhrRequest from "../../src/bug-reporting/requests/xhr-request.js"
|
|
2
|
+
|
|
3
|
+
describe("XhrRequest", () => {
|
|
4
|
+
const originalGlobals = {
|
|
5
|
+
XMLHttpRequest: globalThis.XMLHttpRequest
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let lastXhr = null
|
|
9
|
+
|
|
10
|
+
class FakeXHR {
|
|
11
|
+
constructor() {
|
|
12
|
+
lastXhr = this
|
|
13
|
+
this.headers = {}
|
|
14
|
+
this.responseText = ""
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
open(method, url, async) {
|
|
18
|
+
this.method = method
|
|
19
|
+
this.url = url
|
|
20
|
+
this.async = async
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setRequestHeader(key, value) {
|
|
24
|
+
this.headers[key] = value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
send(body) {
|
|
28
|
+
this.requestBody = body
|
|
29
|
+
this.responseText = JSON.stringify({})
|
|
30
|
+
if (this.onload) {
|
|
31
|
+
this.onload()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
globalThis.XMLHttpRequest = FakeXHR
|
|
38
|
+
lastXhr = null
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
globalThis.XMLHttpRequest = originalGlobals.XMLHttpRequest
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("sends requests with XMLHttpRequest", async () => {
|
|
46
|
+
const request = new XhrRequest()
|
|
47
|
+
const responseText = await request.send("https://example.test/bugs", {hello: "world"})
|
|
48
|
+
|
|
49
|
+
expect(responseText).toBe("{}")
|
|
50
|
+
expect(lastXhr.method).toBe("POST")
|
|
51
|
+
expect(lastXhr.url).toBe("https://example.test/bugs")
|
|
52
|
+
expect(lastXhr.headers["Content-Type"]).toBe("application/json")
|
|
53
|
+
expect(lastXhr.requestBody).toBe(JSON.stringify({hello: "world"}))
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -4,21 +4,61 @@ import envSense from "env-sense"
|
|
|
4
4
|
import RailsVariables from "./variables.js"
|
|
5
5
|
import {debuggerInstance} from "../debugger.js"
|
|
6
6
|
import SourceMapsLoader from "@kaspernj/api-maker/build/source-maps-loader.js"
|
|
7
|
+
import XhrRequest from "./requests/xhr-request.js"
|
|
8
|
+
import FetchRequest from "./requests/fetch-request.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{send: (postUrl: string, postData: object) => Promise<string|null>}} RequestHandler
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {new () => RequestHandler} RequestClass
|
|
16
|
+
*/
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Handles client-side error reporting for Peakflow.
|
|
10
20
|
*/
|
|
11
21
|
export default class BugReporting {
|
|
12
22
|
/**
|
|
13
|
-
* @param {{authToken: string}} data
|
|
23
|
+
* @param {{authToken: string, RequestClass?: RequestClass}} data
|
|
14
24
|
*/
|
|
15
25
|
constructor(data) {
|
|
16
|
-
|
|
26
|
+
const {authToken, RequestClass, ...restArgs} = data || {}
|
|
27
|
+
const unknownKeys = Object.keys(restArgs)
|
|
28
|
+
|
|
29
|
+
if (unknownKeys.length) {
|
|
30
|
+
throw new Error(`Unknown BugReporting options: ${unknownKeys.join(", ")}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!authToken) {
|
|
34
|
+
throw new Error("BugReporting requires an authToken.")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.authToken = authToken
|
|
17
38
|
this.testing = false
|
|
18
39
|
this.collectEnvironmentCallback = undefined
|
|
19
40
|
this.collectParamsCallback = undefined
|
|
20
41
|
this.sourceMapsLoader = null
|
|
21
42
|
this.envSenseResult = null
|
|
43
|
+
this.requestHandler = null
|
|
44
|
+
|
|
45
|
+
if (RequestClass) {
|
|
46
|
+
if (typeof RequestClass !== "function") {
|
|
47
|
+
throw new Error("RequestClass must be a constructor.")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.requestHandler = new RequestClass()
|
|
51
|
+
|
|
52
|
+
if (typeof this.requestHandler?.send !== "function") {
|
|
53
|
+
throw new Error("RequestClass must implement a send(postUrl, postData) method.")
|
|
54
|
+
}
|
|
55
|
+
} else if (typeof XMLHttpRequest !== "undefined") {
|
|
56
|
+
this.requestHandler = new XhrRequest()
|
|
57
|
+
} else if (typeof globalThis.fetch === "function") {
|
|
58
|
+
this.requestHandler = new FetchRequest()
|
|
59
|
+
} else {
|
|
60
|
+
throw new Error("No safe request transport is available. Provide RequestClass.")
|
|
61
|
+
}
|
|
22
62
|
}
|
|
23
63
|
|
|
24
64
|
/**
|
|
@@ -386,100 +426,13 @@ export default class BugReporting {
|
|
|
386
426
|
}
|
|
387
427
|
|
|
388
428
|
/**
|
|
389
|
-
* Send
|
|
390
|
-
* @param {XMLHttpRequest} xhr
|
|
391
|
-
* @param {string} postData
|
|
392
|
-
* @returns {Promise<void>}
|
|
393
|
-
*/
|
|
394
|
-
loadXhr(xhr, postData) {
|
|
395
|
-
return new Promise((resolve) => {
|
|
396
|
-
xhr.onload = () => resolve()
|
|
397
|
-
xhr.send(postData)
|
|
398
|
-
})
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Send error report using the best available transport.
|
|
429
|
+
* Send error report using the configured transport.
|
|
403
430
|
* @param {string} postUrl
|
|
404
431
|
* @param {object} postData
|
|
405
432
|
* @returns {Promise<string|null>}
|
|
406
433
|
*/
|
|
407
434
|
async sendErrorReport(postUrl, postData) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if (typeof XMLHttpRequest !== "undefined") {
|
|
411
|
-
const xhr = new XMLHttpRequest()
|
|
412
|
-
|
|
413
|
-
xhr.open("POST", postUrl, true)
|
|
414
|
-
xhr.setRequestHeader("Content-Type", "application/json")
|
|
415
|
-
|
|
416
|
-
await this.loadXhr(xhr, body)
|
|
417
|
-
return xhr.responseText
|
|
418
|
-
} else if (typeof globalThis.fetch === "function") {
|
|
419
|
-
const response = await globalThis.fetch(postUrl, {
|
|
420
|
-
method: "POST",
|
|
421
|
-
headers: {"Content-Type": "application/json"},
|
|
422
|
-
body
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
return await response.text()
|
|
426
|
-
} else if (this.isNode()) {
|
|
427
|
-
return await this.sendNodeRequest(postUrl, body)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
debuggerInstance.debug("Skipping error report because no HTTP transport is available")
|
|
431
|
-
return null
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Send an HTTP request using Node's built-in modules.
|
|
436
|
-
* @param {string} postUrl
|
|
437
|
-
* @param {string} body
|
|
438
|
-
* @returns {Promise<string|null>}
|
|
439
|
-
*/
|
|
440
|
-
async sendNodeRequest(postUrl, body) {
|
|
441
|
-
/** @type {(specifier: string) => Promise<unknown>} */
|
|
442
|
-
const runtimeImport = (specifier) => {
|
|
443
|
-
const loader = new Function("specifier", "return import(specifier)")
|
|
444
|
-
return loader(specifier)
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const httpsSpecifier = ["node", "https"].join(":")
|
|
448
|
-
const urlSpecifier = ["node", "url"].join(":")
|
|
449
|
-
|
|
450
|
-
const {request} = /** @type {{request: typeof import("node:https").request}} */ (
|
|
451
|
-
await runtimeImport(httpsSpecifier)
|
|
452
|
-
)
|
|
453
|
-
const {URL} = /** @type {{URL: typeof import("node:url").URL}} */ (
|
|
454
|
-
await runtimeImport(urlSpecifier)
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
return await new Promise((resolve) => {
|
|
458
|
-
const url = new URL(postUrl)
|
|
459
|
-
|
|
460
|
-
const req = request({
|
|
461
|
-
protocol: url.protocol,
|
|
462
|
-
hostname: url.hostname,
|
|
463
|
-
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
464
|
-
path: `${url.pathname}${url.search}`,
|
|
465
|
-
method: "POST",
|
|
466
|
-
headers: {
|
|
467
|
-
"Content-Type": "application/json",
|
|
468
|
-
"Content-Length": Buffer.byteLength(body)
|
|
469
|
-
}
|
|
470
|
-
}, (res) => {
|
|
471
|
-
let data = ""
|
|
472
|
-
res.setEncoding("utf8")
|
|
473
|
-
res.on("data", (chunk) => {
|
|
474
|
-
data += chunk
|
|
475
|
-
})
|
|
476
|
-
res.on("end", () => resolve(data))
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
req.on("error", () => resolve(null))
|
|
480
|
-
req.write(body)
|
|
481
|
-
req.end()
|
|
482
|
-
})
|
|
435
|
+
return await this.requestHandler.send(postUrl, postData)
|
|
483
436
|
}
|
|
484
437
|
|
|
485
438
|
/**
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Send JSON payloads with fetch. */
|
|
2
|
+
export default class FetchRequest {
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} postUrl
|
|
5
|
+
* @param {object} postData
|
|
6
|
+
* @returns {Promise<string|null>}
|
|
7
|
+
*/
|
|
8
|
+
async send(postUrl, postData) {
|
|
9
|
+
if (typeof globalThis.fetch !== "function") {
|
|
10
|
+
throw new Error("fetch is not available in this environment.")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const response = await globalThis.fetch(postUrl, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: {"Content-Type": "application/json"},
|
|
16
|
+
body: JSON.stringify(postData)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
return await response.text()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Send JSON payloads with Node's https module. */
|
|
2
|
+
export default class NodeRequest {
|
|
3
|
+
/**
|
|
4
|
+
* @param {{runtimeImport?: (specifier: string) => Promise<unknown>}} [options]
|
|
5
|
+
*/
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
if (options.runtimeImport) {
|
|
8
|
+
this.runtimeImport = options.runtimeImport
|
|
9
|
+
} else {
|
|
10
|
+
this.runtimeImport = (specifier) => {
|
|
11
|
+
const loader = new Function("specifier", "return import(specifier)")
|
|
12
|
+
return loader(specifier)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} postUrl
|
|
19
|
+
* @param {object} postData
|
|
20
|
+
* @returns {Promise<string|null>}
|
|
21
|
+
*/
|
|
22
|
+
async send(postUrl, postData) {
|
|
23
|
+
const httpsSpecifier = ["node", "https"].join(":")
|
|
24
|
+
const urlSpecifier = ["node", "url"].join(":")
|
|
25
|
+
|
|
26
|
+
const {request} = /** @type {{request: typeof import("node:https").request}} */ (
|
|
27
|
+
await this.runtimeImport(httpsSpecifier)
|
|
28
|
+
)
|
|
29
|
+
const {URL} = /** @type {{URL: typeof import("node:url").URL}} */ (
|
|
30
|
+
await this.runtimeImport(urlSpecifier)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const body = JSON.stringify(postData)
|
|
34
|
+
|
|
35
|
+
return await new Promise((resolve) => {
|
|
36
|
+
const url = new URL(postUrl)
|
|
37
|
+
|
|
38
|
+
const req = request({
|
|
39
|
+
protocol: url.protocol,
|
|
40
|
+
hostname: url.hostname,
|
|
41
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
42
|
+
path: `${url.pathname}${url.search}`,
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"Content-Length": Buffer.byteLength(body)
|
|
47
|
+
}
|
|
48
|
+
}, (res) => {
|
|
49
|
+
let data = ""
|
|
50
|
+
res.setEncoding("utf8")
|
|
51
|
+
res.on("data", (chunk) => {
|
|
52
|
+
data += chunk
|
|
53
|
+
})
|
|
54
|
+
res.on("end", () => resolve(data))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
req.on("error", () => resolve(null))
|
|
58
|
+
req.write(body)
|
|
59
|
+
req.end()
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Send JSON payloads with XMLHttpRequest. */
|
|
2
|
+
export default class XhrRequest {
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} postUrl
|
|
5
|
+
* @param {object} postData
|
|
6
|
+
* @returns {Promise<string|null>}
|
|
7
|
+
*/
|
|
8
|
+
async send(postUrl, postData) {
|
|
9
|
+
if (typeof XMLHttpRequest === "undefined") {
|
|
10
|
+
throw new Error("XMLHttpRequest is not available in this environment.")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const xhr = new XMLHttpRequest()
|
|
14
|
+
|
|
15
|
+
xhr.open("POST", postUrl, true)
|
|
16
|
+
xhr.setRequestHeader("Content-Type", "application/json")
|
|
17
|
+
|
|
18
|
+
await new Promise((resolve) => {
|
|
19
|
+
xhr.onload = () => resolve()
|
|
20
|
+
xhr.send(JSON.stringify(postData))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return xhr.responseText
|
|
24
|
+
}
|
|
25
|
+
}
|