@patchstack/connect 0.4.1 → 0.5.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.
Files changed (34) hide show
  1. package/AGENT-INSTALL.md +21 -4
  2. package/README.md +10 -2
  3. package/dist/cli.js +277 -56
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.cjs +221 -18
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +37 -0
  8. package/dist/index.d.ts +37 -0
  9. package/dist/index.js +218 -15
  10. package/dist/index.js.map +1 -1
  11. package/dist/protect/templates/astro-middleware.ts +22 -1
  12. package/dist/protect/templates/express-guard.cjs +54 -4
  13. package/dist/protect/templates/express-guard.js +54 -4
  14. package/dist/protect/templates/express-guard.ts +43 -5
  15. package/dist/protect/templates/fastify-plugin.cjs +41 -3
  16. package/dist/protect/templates/fastify-plugin.js +41 -3
  17. package/dist/protect/templates/fastify-plugin.ts +30 -2
  18. package/dist/protect/templates/generic-guard.cjs +58 -5
  19. package/dist/protect/templates/generic-guard.js +58 -5
  20. package/dist/protect/templates/generic-guard.ts +47 -4
  21. package/dist/protect/templates/guard.ts +42 -8
  22. package/dist/protect/templates/next-middleware.ts +22 -1
  23. package/dist/protect/templates/nuxt-middleware.ts +22 -1
  24. package/dist/protect/templates/sveltekit-hooks.ts +22 -1
  25. package/dist/protect.cjs +341 -94
  26. package/dist/protect.cjs.map +1 -1
  27. package/dist/protect.edge.js +56 -24
  28. package/dist/protect.edge.js.map +1 -1
  29. package/dist/protect.js +57 -25
  30. package/dist/protect.js.map +1 -1
  31. package/dist/{refresh-manifest-SWCPQ52Z.js → refresh-manifest-LDS35UKV.js} +234 -33
  32. package/dist/refresh-manifest-LDS35UKV.js.map +1 -0
  33. package/package.json +4 -4
  34. package/dist/refresh-manifest-SWCPQ52Z.js.map +0 -1
@@ -1,6 +1,16 @@
1
1
  // Patchstack runtime guard for CommonJS Express apps. Managed by `patchstack-connect protect`.
2
2
  const { createProtection } = require("@patchstack/connect/protect");
3
- const fallbackRules = require("./rules.json");
3
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
4
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
5
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
6
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
7
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
8
+ let fallbackRules;
9
+ try {
10
+ fallbackRules = require("./rules.json");
11
+ } catch (err) {
12
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
13
+ }
4
14
 
5
15
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
6
16
  let protection;
@@ -37,10 +47,50 @@ async function buildProtection() {
37
47
  );
38
48
  }
39
49
 
50
+
51
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
52
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
53
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
54
+ // does not switch protection off for the life of the process.
55
+ //
56
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
57
+ // request. A later failure is not reported, including one with a different cause.
58
+ let psUnavailable = false;
59
+ function psStepAside(err) {
60
+ if (!psUnavailable) {
61
+ psUnavailable = true;
62
+ console.warn(
63
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
64
+ (err instanceof Error ? err.message : String(err)),
65
+ );
66
+ }
67
+
68
+ return null;
69
+ }
40
70
  function patchstackMiddleware(req, res, next) {
41
- getProtection()
42
- .then((active) => active.express()(req, res, next))
43
- .catch(() => next());
71
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
72
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
73
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
74
+ // request on that was already passed on, nor leave one that never was.
75
+ let passedOn = false;
76
+ const carryOn = (err) => {
77
+ if (passedOn) return;
78
+ passedOn = true;
79
+ next(err);
80
+ };
81
+ getProtection().then(
82
+ (active) => active.express()(req, res, carryOn),
83
+ (err) => {
84
+ psStepAside(err);
85
+ carryOn();
86
+ },
87
+ ).catch((err) => {
88
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
89
+ // request is carried on only if it never was — an error here must not take the process down and
90
+ // must not answer twice.
91
+ psStepAside(err);
92
+ carryOn();
93
+ });
44
94
  }
45
95
 
46
96
  module.exports = { patchstackMiddleware };
@@ -2,7 +2,17 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { createProtection } from "@patchstack/connect/protect";
4
4
 
5
- const fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
5
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
6
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
7
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
8
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
9
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
10
+ let fallbackRules;
11
+ try {
12
+ fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
13
+ } catch (err) {
14
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
15
+ }
6
16
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
7
17
  let protection;
8
18
 
@@ -38,8 +48,48 @@ async function buildProtection() {
38
48
  );
39
49
  }
40
50
 
51
+
52
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
53
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
54
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
55
+ // does not switch protection off for the life of the process.
56
+ //
57
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
58
+ // request. A later failure is not reported, including one with a different cause.
59
+ let psUnavailable = false;
60
+ function psStepAside(err) {
61
+ if (!psUnavailable) {
62
+ psUnavailable = true;
63
+ console.warn(
64
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
65
+ (err instanceof Error ? err.message : String(err)),
66
+ );
67
+ }
68
+
69
+ return null;
70
+ }
41
71
  export function patchstackMiddleware(req, res, next) {
42
- getProtection()
43
- .then((active) => active.express()(req, res, next))
44
- .catch(() => next());
72
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
73
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
74
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
75
+ // request on that was already passed on, nor leave one that never was.
76
+ let passedOn = false;
77
+ const carryOn = (err) => {
78
+ if (passedOn) return;
79
+ passedOn = true;
80
+ next(err);
81
+ };
82
+ getProtection().then(
83
+ (active) => active.express()(req, res, carryOn),
84
+ (err) => {
85
+ psStepAside(err);
86
+ carryOn();
87
+ },
88
+ ).catch((err) => {
89
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
90
+ // request is carried on only if it never was — an error here must not take the process down and
91
+ // must not answer twice.
92
+ psStepAside(err);
93
+ carryOn();
94
+ });
45
95
  }
@@ -44,10 +44,48 @@ async function buildProtection() {
44
44
  );
45
45
  }
46
46
 
47
+
48
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
49
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
50
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
51
+ // does not switch protection off for the life of the process.
52
+ //
53
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
54
+ // request. A later failure is not reported, including one with a different cause.
55
+ let psUnavailable = false;
56
+ function psStepAside(err: unknown) {
57
+ if (!psUnavailable) {
58
+ psUnavailable = true;
59
+ console.warn(
60
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
61
+ (err instanceof Error ? err.message : String(err)),
62
+ );
63
+ }
64
+
65
+ return null;
66
+ }
47
67
  export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: unknown) => void) {
48
- getProtection()
49
- .then((protection) =>
50
- (protection.express() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, next),
51
- )
52
- .catch(() => next());
68
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
69
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
70
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
71
+ // request on that was already passed on, nor leave one that never was.
72
+ let passedOn = false;
73
+ const carryOn = (err?: unknown) => {
74
+ if (passedOn) return;
75
+ passedOn = true;
76
+ next(err);
77
+ };
78
+ getProtection().then(
79
+ (protection) => (protection.express() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
80
+ (err) => {
81
+ psStepAside(err);
82
+ carryOn();
83
+ },
84
+ ).catch((err) => {
85
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
86
+ // request is carried on only if it never was — an error here must not take the process down and
87
+ // must not answer twice.
88
+ psStepAside(err);
89
+ carryOn();
90
+ });
53
91
  }
@@ -1,7 +1,17 @@
1
1
  // Patchstack runtime guard for CommonJS Fastify apps. Managed by `patchstack-connect protect`.
2
2
  // Register once: app.register(patchstackFastify)
3
3
  const { createProtection } = require("@patchstack/connect/protect");
4
- const fallbackRules = require("./rules.json");
4
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
5
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
6
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
7
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
8
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
9
+ let fallbackRules;
10
+ try {
11
+ fallbackRules = require("./rules.json");
12
+ } catch (err) {
13
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
14
+ }
5
15
 
6
16
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
7
17
  let protection;
@@ -34,10 +44,38 @@ async function buildProtection() {
34
44
  );
35
45
  }
36
46
 
47
+
48
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
49
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
50
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
51
+ // does not switch protection off for the life of the process.
52
+ //
53
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
54
+ // request. A later failure is not reported, including one with a different cause.
55
+ let psUnavailable = false;
56
+ function psStepAside(err) {
57
+ if (!psUnavailable) {
58
+ psUnavailable = true;
59
+ console.warn(
60
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
61
+ (err instanceof Error ? err.message : String(err)),
62
+ );
63
+ }
64
+
65
+ return null;
66
+ }
37
67
  async function patchstackFastify(fastify) {
38
- const protection = await getProtection();
39
- const guard = protection.fetchGuard();
68
+ // Built here, at registration, and NOT only on the first request: building the protection is also
69
+ // what installs egress screening, and an app's startup work makes outbound calls before any request
70
+ // arrives. A failure is swallowed rather than propagated, because registering a plugin must not be
71
+ // able to fail — and the hook asks again, so a build that failed at startup is retried rather than
72
+ // leaving every route unscreened for the life of the process.
73
+ await getProtection().catch(psStepAside);
74
+
40
75
  fastify.addHook("preHandler", async (request, reply) => {
76
+ const protection = await getProtection().catch(psStepAside);
77
+ if (!protection) return; // the route answers this request, unscreened
78
+ const guard = protection.fetchGuard();
41
79
  const method = (request.method ?? "GET").toUpperCase();
42
80
  const host = request.headers?.host ?? "localhost";
43
81
  const url = `http://${host}${request.url ?? "/"}`;
@@ -3,7 +3,17 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { createProtection } from "@patchstack/connect/protect";
5
5
 
6
- const fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
6
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
7
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
8
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
9
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
10
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
11
+ let fallbackRules;
12
+ try {
13
+ fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
14
+ } catch (err) {
15
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
16
+ }
7
17
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
8
18
  let protection;
9
19
 
@@ -35,10 +45,38 @@ async function buildProtection() {
35
45
  );
36
46
  }
37
47
 
48
+
49
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
50
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
51
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
52
+ // does not switch protection off for the life of the process.
53
+ //
54
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
55
+ // request. A later failure is not reported, including one with a different cause.
56
+ let psUnavailable = false;
57
+ function psStepAside(err) {
58
+ if (!psUnavailable) {
59
+ psUnavailable = true;
60
+ console.warn(
61
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
62
+ (err instanceof Error ? err.message : String(err)),
63
+ );
64
+ }
65
+
66
+ return null;
67
+ }
38
68
  export async function patchstackFastify(fastify) {
39
- const protection = await getProtection();
40
- const guard = protection.fetchGuard();
69
+ // Built here, at registration, and NOT only on the first request: building the protection is also
70
+ // what installs egress screening, and an app's startup work makes outbound calls before any request
71
+ // arrives. A failure is swallowed rather than propagated, because registering a plugin must not be
72
+ // able to fail — and the hook asks again, so a build that failed at startup is retried rather than
73
+ // leaving every route unscreened for the life of the process.
74
+ await getProtection().catch(psStepAside);
75
+
41
76
  fastify.addHook("preHandler", async (request, reply) => {
77
+ const protection = await getProtection().catch(psStepAside);
78
+ if (!protection) return; // the route answers this request, unscreened
79
+ const guard = protection.fetchGuard();
42
80
  const method = (request.method ?? "GET").toUpperCase();
43
81
  const host = request.headers?.host ?? "localhost";
44
82
  const url = `http://${host}${request.url ?? "/"}`;
@@ -43,10 +43,38 @@ async function buildProtection() {
43
43
  }
44
44
 
45
45
  // #region patchstack-fastify (managed by patchstack-connect protect — do not edit)
46
+
47
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
48
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
49
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
50
+ // does not switch protection off for the life of the process.
51
+ //
52
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
53
+ // request. A later failure is not reported, including one with a different cause.
54
+ let psUnavailable = false;
55
+ function psStepAside(err: unknown) {
56
+ if (!psUnavailable) {
57
+ psUnavailable = true;
58
+ console.warn(
59
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
60
+ (err instanceof Error ? err.message : String(err)),
61
+ );
62
+ }
63
+
64
+ return null;
65
+ }
46
66
  export async function patchstackFastify(fastify: any) {
47
- const protection = await getProtection();
48
- const guard = protection.fetchGuard();
67
+ // Built here, at registration, and NOT only on the first request: building the protection is also
68
+ // what installs egress screening, and an app's startup work makes outbound calls before any request
69
+ // arrives. A failure is swallowed rather than propagated, because registering a plugin must not be
70
+ // able to fail — and the hook asks again, so a build that failed at startup is retried rather than
71
+ // leaving every route unscreened for the life of the process.
72
+ await getProtection().catch(psStepAside);
73
+
49
74
  fastify.addHook("preHandler", async (request: any, reply: any) => {
75
+ const protection = await getProtection().catch(psStepAside);
76
+ if (!protection) return; // the route answers this request, unscreened
77
+ const guard = protection.fetchGuard();
50
78
  const method = (request.method ?? "GET").toUpperCase();
51
79
  const host = request.headers?.host ?? "localhost";
52
80
  const url = `http://${host}${request.url ?? "/"}`;
@@ -1,7 +1,17 @@
1
1
  // Patchstack runtime protection — GENERIC guard (CommonJS). Managed by `patchstack-connect protect`.
2
2
  // Wire whichever helper fits your server into your request path, then run `protect --check`.
3
3
  const { createProtection } = require("@patchstack/connect/protect");
4
- const fallbackRules = require("./rules.json");
4
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
5
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
6
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
7
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
8
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
9
+ let fallbackRules;
10
+ try {
11
+ fallbackRules = require("./rules.json");
12
+ } catch (err) {
13
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
14
+ }
5
15
 
6
16
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
7
17
  let protection;
@@ -34,9 +44,32 @@ async function buildProtection() {
34
44
  );
35
45
  }
36
46
 
47
+
48
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
49
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
50
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
51
+ // does not switch protection off for the life of the process.
52
+ //
53
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
54
+ // request. A later failure is not reported, including one with a different cause.
55
+ let psUnavailable = false;
56
+ function psStepAside(err) {
57
+ if (!psUnavailable) {
58
+ psUnavailable = true;
59
+ console.warn(
60
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
61
+ (err instanceof Error ? err.message : String(err)),
62
+ );
63
+ }
64
+
65
+ return null;
66
+ }
37
67
  function protectFetch(handler) {
38
68
  return async (request, ...rest) => {
39
- const active = await getProtection();
69
+ const active = await getProtection().catch(psStepAside);
70
+ // The handler, and nothing around it: an exception the app throws is the app's, not a protection
71
+ // failure, and must not be read as one or answered twice.
72
+ if (!active) return handler(request, ...rest);
40
73
  const blocked = await active.fetchGuard()(request);
41
74
  if (blocked) return blocked;
42
75
  // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
@@ -49,9 +82,29 @@ function protectFetch(handler) {
49
82
  // Node / Connect: app.use(patchstackMiddleware) — before any body parser. This guard reads the request
50
83
  // stream itself and exposes what it read as req.body, so a parser is not also needed.
51
84
  function patchstackMiddleware(req, res, next) {
52
- getProtection()
53
- .then((active) => active.node()(req, res, next))
54
- .catch(() => next()); // fail open
85
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
86
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
87
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
88
+ // request on that was already passed on, nor leave one that never was.
89
+ let passedOn = false;
90
+ const carryOn = (err) => {
91
+ if (passedOn) return;
92
+ passedOn = true;
93
+ next(err);
94
+ };
95
+ getProtection().then(
96
+ (active) => active.node()(req, res, carryOn),
97
+ (err) => {
98
+ psStepAside(err);
99
+ carryOn();
100
+ },
101
+ ).catch((err) => {
102
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
103
+ // request is carried on only if it never was — an error here must not take the process down and
104
+ // must not answer twice.
105
+ psStepAside(err);
106
+ carryOn();
107
+ });
55
108
  }
56
109
 
57
110
  module.exports = { getProtection, protectFetch, patchstackMiddleware };
@@ -3,7 +3,17 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { createProtection } from "@patchstack/connect/protect";
5
5
 
6
- const fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
6
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
7
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
8
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
9
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
10
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
11
+ let fallbackRules;
12
+ try {
13
+ fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
14
+ } catch (err) {
15
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
16
+ }
7
17
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
8
18
  let protection;
9
19
 
@@ -35,10 +45,33 @@ async function buildProtection() {
35
45
  );
36
46
  }
37
47
 
48
+
49
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
50
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
51
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
52
+ // does not switch protection off for the life of the process.
53
+ //
54
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
55
+ // request. A later failure is not reported, including one with a different cause.
56
+ let psUnavailable = false;
57
+ function psStepAside(err) {
58
+ if (!psUnavailable) {
59
+ psUnavailable = true;
60
+ console.warn(
61
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
62
+ (err instanceof Error ? err.message : String(err)),
63
+ );
64
+ }
65
+
66
+ return null;
67
+ }
38
68
  // Web-Fetch: export default { fetch: protectFetch(originalFetch) }
39
69
  export function protectFetch(handler) {
40
70
  return async (request, ...rest) => {
41
- const active = await getProtection();
71
+ const active = await getProtection().catch(psStepAside);
72
+ // The handler, and nothing around it: an exception the app throws is the app's, not a protection
73
+ // failure, and must not be read as one or answered twice.
74
+ if (!active) return handler(request, ...rest);
42
75
  const blocked = await active.fetchGuard()(request);
43
76
  if (blocked) return blocked;
44
77
  // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
@@ -51,7 +84,27 @@ export function protectFetch(handler) {
51
84
  // Node / Connect: app.use(patchstackMiddleware) — before any body parser. This guard reads the request
52
85
  // stream itself and exposes what it read as req.body, so a parser is not also needed.
53
86
  export function patchstackMiddleware(req, res, next) {
54
- getProtection()
55
- .then((active) => active.node()(req, res, next))
56
- .catch(() => next()); // fail open
87
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
88
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
89
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
90
+ // request on that was already passed on, nor leave one that never was.
91
+ let passedOn = false;
92
+ const carryOn = (err) => {
93
+ if (passedOn) return;
94
+ passedOn = true;
95
+ next(err);
96
+ };
97
+ getProtection().then(
98
+ (active) => active.node()(req, res, carryOn),
99
+ (err) => {
100
+ psStepAside(err);
101
+ carryOn();
102
+ },
103
+ ).catch((err) => {
104
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
105
+ // request is carried on only if it never was — an error here must not take the process down and
106
+ // must not answer twice.
107
+ psStepAside(err);
108
+ carryOn();
109
+ });
57
110
  }
@@ -52,11 +52,34 @@ async function buildProtection() {
52
52
  );
53
53
  }
54
54
 
55
+
56
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
57
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
58
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
59
+ // does not switch protection off for the life of the process.
60
+ //
61
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
62
+ // request. A later failure is not reported, including one with a different cause.
63
+ let psUnavailable = false;
64
+ function psStepAside(err: unknown) {
65
+ if (!psUnavailable) {
66
+ psUnavailable = true;
67
+ console.warn(
68
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
69
+ (err instanceof Error ? err.message : String(err)),
70
+ );
71
+ }
72
+
73
+ return null;
74
+ }
55
75
  // --- Web Fetch (Cloudflare Workers, Bun, Deno, Hono, Next edge, TanStack server.ts) ---------
56
76
  // Wrap your fetch handler: export default { fetch: protectFetch(originalFetch) }
57
77
  export function protectFetch<H extends (request: Request, ...rest: unknown[]) => unknown>(handler: H): H {
58
78
  return (async (request: Request, ...rest: unknown[]) => {
59
- const protection = await getProtection();
79
+ const protection = await getProtection().catch(psStepAside);
80
+ // The handler, and nothing around it: an exception the app throws is the app's, not a protection
81
+ // failure, and must not be read as one or answered twice.
82
+ if (!protection) return handler(request, ...rest);
60
83
  const blocked = await protection.fetchGuard()(request);
61
84
  if (blocked) return blocked;
62
85
  // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
@@ -70,7 +93,27 @@ export function protectFetch<H extends (request: Request, ...rest: unknown[]) =>
70
93
  // app.use(patchstackMiddleware) — register it before any body parser and before your routes. This guard
71
94
  // reads the request stream itself and exposes what it read as req.body, so a parser is not also needed.
72
95
  export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: unknown) => void) {
73
- getProtection()
74
- .then((protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, next))
75
- .catch(() => next()); // fail open
96
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
97
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
98
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
99
+ // request on that was already passed on, nor leave one that never was.
100
+ let passedOn = false;
101
+ const carryOn = (err?: unknown) => {
102
+ if (passedOn) return;
103
+ passedOn = true;
104
+ next(err);
105
+ };
106
+ getProtection().then(
107
+ (protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
108
+ (err) => {
109
+ psStepAside(err);
110
+ carryOn();
111
+ },
112
+ ).catch((err) => {
113
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
114
+ // request is carried on only if it never was — an error here must not take the process down and
115
+ // must not answer twice.
116
+ psStepAside(err);
117
+ carryOn();
118
+ });
76
119
  }