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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peakflow-api",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -162,4 +162,5 @@ describe("BugReporting", () => {
162
162
  }))
163
163
  expect(previousHandlerCalled).toBeTrue()
164
164
  })
165
+
165
166
  })
@@ -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
- this.authToken = data.authToken
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 JSON data through an XHR instance.
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
- const body = JSON.stringify(postData)
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
+ }