facebook-mcp-server 1.6.6

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 (64) hide show
  1. package/.env.example +2 -0
  2. package/.github/dependabot.yml +50 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/release.yml +200 -0
  5. package/CONTRIBUTING.md +112 -0
  6. package/LICENSE +21 -0
  7. package/README.md +128 -0
  8. package/dist/client.d.ts +57 -0
  9. package/dist/client.js +140 -0
  10. package/dist/client.test.d.ts +9 -0
  11. package/dist/client.test.js +211 -0
  12. package/dist/create-post.d.ts +39 -0
  13. package/dist/create-post.js +85 -0
  14. package/dist/create-post.test.d.ts +11 -0
  15. package/dist/create-post.test.js +175 -0
  16. package/dist/errors.d.ts +12 -0
  17. package/dist/errors.js +87 -0
  18. package/dist/errors.test.d.ts +9 -0
  19. package/dist/errors.test.js +162 -0
  20. package/dist/first-comment.test.d.ts +10 -0
  21. package/dist/first-comment.test.js +54 -0
  22. package/dist/handlers.test.d.ts +19 -0
  23. package/dist/handlers.test.js +333 -0
  24. package/dist/index.d.ts +44 -0
  25. package/dist/index.js +374 -0
  26. package/dist/lib/index.d.ts +9 -0
  27. package/dist/lib/index.js +8 -0
  28. package/dist/lib/insights.d.ts +53 -0
  29. package/dist/lib/insights.js +47 -0
  30. package/dist/rate-limiter.d.ts +71 -0
  31. package/dist/rate-limiter.js +214 -0
  32. package/dist/rate-limiter.test.d.ts +1 -0
  33. package/dist/rate-limiter.test.js +154 -0
  34. package/dist/response.d.ts +24 -0
  35. package/dist/response.js +35 -0
  36. package/dist/response.test.d.ts +1 -0
  37. package/dist/response.test.js +71 -0
  38. package/dist/sanitize.d.ts +17 -0
  39. package/dist/sanitize.js +27 -0
  40. package/dist/sanitize.test.d.ts +1 -0
  41. package/dist/sanitize.test.js +43 -0
  42. package/dist/tools.test.d.ts +16 -0
  43. package/dist/tools.test.js +150 -0
  44. package/package.json +29 -0
  45. package/src/client.test.ts +284 -0
  46. package/src/client.ts +204 -0
  47. package/src/create-post.test.ts +196 -0
  48. package/src/create-post.ts +118 -0
  49. package/src/errors.test.ts +297 -0
  50. package/src/errors.ts +108 -0
  51. package/src/first-comment.test.ts +73 -0
  52. package/src/handlers.test.ts +431 -0
  53. package/src/index.ts +540 -0
  54. package/src/lib/index.ts +9 -0
  55. package/src/lib/insights.ts +150 -0
  56. package/src/rate-limiter.test.ts +186 -0
  57. package/src/rate-limiter.ts +252 -0
  58. package/src/response.test.ts +80 -0
  59. package/src/response.ts +43 -0
  60. package/src/sanitize.test.ts +52 -0
  61. package/src/sanitize.ts +35 -0
  62. package/src/tools.test.ts +195 -0
  63. package/tsconfig.json +15 -0
  64. package/vitest.config.ts +10 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Integration tests for tool-handler helpers.
3
+ *
4
+ * Tests the building blocks every tool uses:
5
+ * - safeHandler — wraps every tool, formats errors
6
+ * - resolveCredentials — env-vs-args precedence
7
+ *
8
+ * Full per-tool happy/error path tests would require spinning up the whole
9
+ * McpServer and sending JSON-RPC messages; instead we unit-test the shared
10
+ * helpers each tool depends on, which covers the surface area that actually
11
+ * breaks during Graph API changes.
12
+ *
13
+ * Note: Facebook publishes are synchronous (no container flow), so there is
14
+ * no pollContainerStatus helper to test.
15
+ */
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
17
+ import { safeHandler, resolveCredentials } from "./index.js";
18
+ import { FacebookApiError } from "./client.js";
19
+ // Silence the console.error logging from safeHandler during tests
20
+ beforeEach(() => {
21
+ vi.spyOn(console, "error").mockImplementation(() => { });
22
+ });
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ vi.unstubAllGlobals();
26
+ });
27
+ describe("resolveCredentials", () => {
28
+ // resolveCredentials reads DEFAULT_* constants captured at import time.
29
+ // We can't mutate them mid-test, but we can verify args-vs-env precedence
30
+ // by controlling args. The env-only path is covered by the "args missing"
31
+ // case returning whatever env happens to contain at import time.
32
+ it("returns null when both args and env are missing", () => {
33
+ // If env is set at import, this test is a no-op, so guard with a stub
34
+ if (!process.env.FACEBOOK_ACCESS_TOKEN || !process.env.FACEBOOK_PAGE_ID) {
35
+ expect(resolveCredentials({})).toBeNull();
36
+ }
37
+ });
38
+ it("returns provided args when both are supplied", () => {
39
+ const r = resolveCredentials({
40
+ accessToken: "tok_from_args",
41
+ pageId: "page_from_args",
42
+ });
43
+ expect(r).toEqual({
44
+ accessToken: "tok_from_args",
45
+ pageId: "page_from_args",
46
+ });
47
+ });
48
+ it("args-provided values take precedence over env defaults", () => {
49
+ // Even if env vars are set (from .env or shell), explicit args win
50
+ const r = resolveCredentials({
51
+ accessToken: "override_token",
52
+ pageId: "override_account",
53
+ });
54
+ expect(r?.accessToken).toBe("override_token");
55
+ expect(r?.pageId).toBe("override_account");
56
+ });
57
+ it("partial args fall through to env (or null if env missing)", () => {
58
+ // Only pageId supplied, accessToken must come from env
59
+ const r = resolveCredentials({ pageId: "page_only" });
60
+ if (process.env.FACEBOOK_ACCESS_TOKEN) {
61
+ expect(r?.pageId).toBe("page_only");
62
+ expect(r?.accessToken).toBe(process.env.FACEBOOK_ACCESS_TOKEN);
63
+ }
64
+ else {
65
+ expect(r).toBeNull();
66
+ }
67
+ });
68
+ });
69
+ describe("safeHandler", () => {
70
+ it("passes through successful handler results", async () => {
71
+ const handler = safeHandler("test_tool", async () => ({
72
+ content: [{ type: "text", text: "success" }],
73
+ }));
74
+ const result = await handler({});
75
+ expect(result).toEqual({
76
+ content: [{ type: "text", text: "success" }],
77
+ });
78
+ expect(result.isError).toBeFalsy();
79
+ });
80
+ it("catches thrown Error and returns errorResult", async () => {
81
+ const handler = safeHandler("test_tool", async () => {
82
+ throw new Error("something broke");
83
+ });
84
+ const result = (await handler({}));
85
+ expect(result.isError).toBe(true);
86
+ const body = JSON.parse(result.content[0].text);
87
+ expect(body.error).toBe("API error");
88
+ expect(body.message).toContain("test_tool failed");
89
+ expect(body.message).toContain("something broke");
90
+ });
91
+ it("catches FacebookApiError with auth-failure detail and sets action=AUTH_FAILED", async () => {
92
+ const handler = safeHandler("fb_get_page_insights", async () => {
93
+ throw new FacebookApiError(400, {
94
+ message: "Invalid OAuth access token - Cannot parse access token",
95
+ type: "OAuthException",
96
+ code: 190,
97
+ });
98
+ });
99
+ const result = (await handler({}));
100
+ const body = JSON.parse(result.content[0].text);
101
+ expect(body.statusCode).toBe(400);
102
+ expect(body.action).toMatch(/^AUTH_FAILED:/);
103
+ });
104
+ it("catches metric-error (Bug #6 regression): does NOT set AUTH_FAILED", async () => {
105
+ const handler = safeHandler("fb_get_page_insights", async () => {
106
+ throw new FacebookApiError(400, {
107
+ message: "(#100) metric[2] must be one of the following values: reach, follower_count",
108
+ type: "OAuthException",
109
+ code: 100,
110
+ });
111
+ });
112
+ const result = (await handler({}));
113
+ const body = JSON.parse(result.content[0].text);
114
+ expect(body.action).not.toMatch(/^AUTH_FAILED:/);
115
+ expect(body.action).toMatch(/^INVALID_REQUEST:/);
116
+ });
117
+ it("catches media-type error (Bug #9 regression): maps subcode 2207052 to INVALID_MEDIA", async () => {
118
+ const handler = safeHandler("fb_create_post", async () => {
119
+ throw new FacebookApiError(400, {
120
+ message: "Only photo or video can be accepted as media type.",
121
+ type: "OAuthException",
122
+ code: 100,
123
+ error_subcode: 2207052,
124
+ });
125
+ });
126
+ const result = (await handler({}));
127
+ const body = JSON.parse(result.content[0].text);
128
+ expect(body.action).toMatch(/^INVALID_MEDIA:/);
129
+ });
130
+ it("catches non-Error throws (string) gracefully", async () => {
131
+ const handler = safeHandler("test_tool", async () => {
132
+ throw "string error";
133
+ });
134
+ const result = (await handler({}));
135
+ expect(result.isError).toBe(true);
136
+ const body = JSON.parse(result.content[0].text);
137
+ expect(body.message).toContain("string error");
138
+ });
139
+ });
140
+ describe("safeHandler — rate-limit error (429 long wait)", () => {
141
+ it("maps 429 to RATE_LIMITED action string", async () => {
142
+ const handler = safeHandler("fb_get_page_insights", async () => {
143
+ throw new FacebookApiError(429, { message: "too fast", type: "OAuthException", code: 4 }, 300);
144
+ });
145
+ const result = (await handler({}));
146
+ const body = JSON.parse(result.content[0].text);
147
+ expect(body.statusCode).toBe(429);
148
+ expect(body.action).toMatch(/^RATE_LIMITED:/);
149
+ });
150
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "facebook-mcp-server",
3
+ "version": "1.6.6",
4
+ "description": "Standalone Facebook Pages MCP Server — SENSE + ACT tools for the Graph API",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "facebook-mcp-server": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "dev": "tsx src/index.ts",
12
+ "build": "tsc",
13
+ "start": "node dist/index.js",
14
+ "test": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.0.0",
18
+ "zod": "^4.1.5"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.0.0",
22
+ "tsx": "^4.19.0",
23
+ "typescript": "^5.7.0",
24
+ "vitest": "^4.0.18"
25
+ },
26
+ "overrides": {
27
+ "fast-uri": "^3.1.2"
28
+ }
29
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Unit tests for the Graph API HTTP client.
3
+ *
4
+ * Uses vi.stubGlobal("fetch", ...) to stub network calls. Every test resets
5
+ * the stub in afterEach to prevent leakage. The most important test here is
6
+ * the one asserting GRAPH_API_BASE points at graph.facebook.com — it's a
7
+ * regression guard ensuring the correct API base URL is used.
8
+ */
9
+
10
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
11
+ import {
12
+ createClient,
13
+ FacebookApiError,
14
+ FacebookClient,
15
+ type Credentials,
16
+ } from "./client.js";
17
+
18
+ // Reach into the module's source to read the base URL constant.
19
+ // We assert on actual request URLs below, which is the real contract.
20
+
21
+ const creds: Credentials = {
22
+ accessToken: "EAALtest123",
23
+ pageId: "548545275018321",
24
+ };
25
+
26
+ type FetchMock = ReturnType<typeof vi.fn<typeof fetch>>;
27
+
28
+ function mockFetchOk(body: unknown, init: ResponseInit = {}): FetchMock {
29
+ return vi.fn<typeof fetch>(
30
+ async () =>
31
+ new Response(JSON.stringify(body), {
32
+ status: 200,
33
+ headers: { "content-type": "application/json" },
34
+ ...init,
35
+ }),
36
+ );
37
+ }
38
+
39
+ function mockFetchError(
40
+ status: number,
41
+ body: unknown,
42
+ headers: Record<string, string> = {},
43
+ ): FetchMock {
44
+ return vi.fn<typeof fetch>(
45
+ async () =>
46
+ new Response(JSON.stringify(body), {
47
+ status,
48
+ headers: { "content-type": "application/json", ...headers },
49
+ }),
50
+ );
51
+ }
52
+
53
+ describe("FacebookClient — URL construction", () => {
54
+ afterEach(() => {
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ it("GET builds URL against graph.facebook.com/v21.0 (REGRESSION: Bug #1)", async () => {
59
+ const fetchMock = mockFetchOk({ data: [] });
60
+ vi.stubGlobal("fetch", fetchMock);
61
+
62
+ const client = new FacebookClient(creds);
63
+ await client.get("/548545275018321/insights");
64
+
65
+ const [url] = fetchMock.mock.calls[0];
66
+ expect(url.toString()).toMatch(
67
+ /^https:\/\/graph\.facebook\.com\/v21\.0\/548545275018321\/insights\?/,
68
+ );
69
+ });
70
+
71
+ it("GET puts access_token and extra params in query string", async () => {
72
+ const fetchMock = mockFetchOk({ data: [] });
73
+ vi.stubGlobal("fetch", fetchMock);
74
+
75
+ const client = new FacebookClient(creds);
76
+ await client.get("/548545275018321/insights", {
77
+ metric: "reach,profile_views",
78
+ period: "day",
79
+ });
80
+
81
+ const url = fetchMock.mock.calls[0][0] as URL;
82
+ expect(url.searchParams.get("access_token")).toBe("EAALtest123");
83
+ expect(url.searchParams.get("metric")).toBe("reach,profile_views");
84
+ expect(url.searchParams.get("period")).toBe("day");
85
+ });
86
+
87
+ it("POST sends JSON body with correct Content-Type", async () => {
88
+ const fetchMock = mockFetchOk({ id: "new_media_id" });
89
+ vi.stubGlobal("fetch", fetchMock);
90
+
91
+ const client = new FacebookClient(creds);
92
+ await client.post("/548545275018321/media", {
93
+ image_url: "https://example.com/test.jpg",
94
+ caption: "hello",
95
+ });
96
+
97
+ const [, init] = fetchMock.mock.calls[0];
98
+ expect(init?.method).toBe("POST");
99
+ const headers = init?.headers as Record<string, string>;
100
+ expect(headers["Content-Type"]).toBe("application/json");
101
+ expect(JSON.parse(init?.body as string)).toEqual({
102
+ image_url: "https://example.com/test.jpg",
103
+ caption: "hello",
104
+ });
105
+ });
106
+
107
+ it("POST includes access_token in query even when body is present", async () => {
108
+ const fetchMock = mockFetchOk({ id: "x" });
109
+ vi.stubGlobal("fetch", fetchMock);
110
+
111
+ const client = new FacebookClient(creds);
112
+ await client.post("/548545275018321/media_publish", {
113
+ creation_id: "abc",
114
+ });
115
+
116
+ const url = fetchMock.mock.calls[0][0] as URL;
117
+ expect(url.searchParams.get("access_token")).toBe("EAALtest123");
118
+ });
119
+
120
+ it("DELETE sends DELETE method", async () => {
121
+ const fetchMock = mockFetchOk({ success: true });
122
+ vi.stubGlobal("fetch", fetchMock);
123
+
124
+ const client = new FacebookClient(creds);
125
+ await client.delete("/comment_id_123");
126
+
127
+ const [, init] = fetchMock.mock.calls[0];
128
+ expect(init?.method).toBe("DELETE");
129
+ });
130
+ });
131
+
132
+ describe("FacebookClient — error handling", () => {
133
+ afterEach(() => {
134
+ vi.unstubAllGlobals();
135
+ });
136
+
137
+ it("parses Graph API error into FacebookApiError with status/code/subcode", async () => {
138
+ vi.stubGlobal(
139
+ "fetch",
140
+ mockFetchError(400, {
141
+ error: {
142
+ message: "Invalid OAuth access token",
143
+ type: "OAuthException",
144
+ code: 190,
145
+ error_subcode: 460,
146
+ fbtrace_id: "abc123",
147
+ },
148
+ }),
149
+ );
150
+
151
+ const client = new FacebookClient(creds);
152
+ await expect(client.get("/me")).rejects.toMatchObject({
153
+ name: "FacebookApiError",
154
+ status: 400,
155
+ code: 190,
156
+ errorSubcode: 460,
157
+ errorType: "OAuthException",
158
+ message: "Invalid OAuth access token",
159
+ });
160
+ });
161
+
162
+ it("wraps non-JSON HTML error responses in FacebookApiError", async () => {
163
+ const fetchMock = vi.fn(
164
+ async () =>
165
+ new Response("<html>502 Bad Gateway</html>", {
166
+ status: 502,
167
+ headers: { "content-type": "text/html" },
168
+ }),
169
+ );
170
+ vi.stubGlobal("fetch", fetchMock);
171
+
172
+ const client = new FacebookClient(creds);
173
+ await expect(client.get("/me")).rejects.toMatchObject({
174
+ status: 502,
175
+ errorType: "ParseError",
176
+ });
177
+ });
178
+
179
+ it("passes through Retry-After header as retryAfter", async () => {
180
+ vi.stubGlobal(
181
+ "fetch",
182
+ mockFetchError(
183
+ 429,
184
+ {
185
+ error: {
186
+ message: "Rate limited",
187
+ type: "OAuthException",
188
+ code: 4,
189
+ },
190
+ },
191
+ { "Retry-After": "90" },
192
+ ),
193
+ );
194
+
195
+ const client = new FacebookClient(creds);
196
+ try {
197
+ await client.get("/me");
198
+ expect.fail("should have thrown");
199
+ } catch (e) {
200
+ expect(e).toBeInstanceOf(FacebookApiError);
201
+ expect((e as FacebookApiError).retryAfter).toBe(90);
202
+ }
203
+ });
204
+
205
+ it("wraps non-GraphAPI JSON error shapes in UnknownError", async () => {
206
+ vi.stubGlobal("fetch", mockFetchError(500, { something: "weird" }));
207
+
208
+ const client = new FacebookClient(creds);
209
+ await expect(client.get("/me")).rejects.toMatchObject({
210
+ status: 500,
211
+ errorType: "UnknownError",
212
+ });
213
+ });
214
+ });
215
+
216
+ describe("createClient — caching", () => {
217
+ beforeEach(() => {
218
+ // Clear module state between tests by re-requiring isn't straightforward
219
+ // in ESM. Instead we rely on the fact that each cache key includes the
220
+ // full token+account pair — using unique tokens per test keeps them
221
+ // isolated.
222
+ });
223
+
224
+ it("returns the same instance for identical credentials", () => {
225
+ const a = createClient({ accessToken: "tok_same", pageId: "page_1" });
226
+ const b = createClient({ accessToken: "tok_same", pageId: "page_1" });
227
+ expect(a).toBe(b);
228
+ });
229
+
230
+ it("returns different instances for different tokens", () => {
231
+ const a = createClient({ accessToken: "tok_A_unique", pageId: "page_x" });
232
+ const b = createClient({ accessToken: "tok_B_unique", pageId: "page_x" });
233
+ expect(a).not.toBe(b);
234
+ });
235
+
236
+ it("returns different instances for different account IDs", () => {
237
+ const a = createClient({ accessToken: "tok_shared", pageId: "page_1_u" });
238
+ const b = createClient({ accessToken: "tok_shared", pageId: "page_2_u" });
239
+ expect(a).not.toBe(b);
240
+ });
241
+
242
+ it("returned clients carry the provided creds", () => {
243
+ const client = createClient({
244
+ accessToken: "tok_readback",
245
+ pageId: "page_readback",
246
+ });
247
+ expect(client.accessToken).toBe("tok_readback");
248
+ expect(client.pageId).toBe("page_readback");
249
+ });
250
+ });
251
+
252
+ describe("FacebookApiError — shape", () => {
253
+ it("carries all graph-api error fields", () => {
254
+ const err = new FacebookApiError(
255
+ 400,
256
+ {
257
+ message: "boom",
258
+ type: "OAuthException",
259
+ code: 100,
260
+ error_subcode: 2207052,
261
+ },
262
+ 30,
263
+ );
264
+
265
+ expect(err).toBeInstanceOf(Error);
266
+ expect(err.name).toBe("FacebookApiError");
267
+ expect(err.status).toBe(400);
268
+ expect(err.code).toBe(100);
269
+ expect(err.errorSubcode).toBe(2207052);
270
+ expect(err.errorType).toBe("OAuthException");
271
+ expect(err.retryAfter).toBe(30);
272
+ expect(err.message).toBe("boom");
273
+ });
274
+
275
+ it("allows undefined subcode and retryAfter", () => {
276
+ const err = new FacebookApiError(500, {
277
+ message: "server error",
278
+ type: "InternalError",
279
+ code: 1,
280
+ });
281
+ expect(err.errorSubcode).toBeUndefined();
282
+ expect(err.retryAfter).toBeUndefined();
283
+ });
284
+ });
package/src/client.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Facebook Graph API client.
3
+ *
4
+ * Raw fetch wrapper against https://graph.facebook.com/v21.0/.
5
+ * No SDK — keeps dependencies minimal (Occam's Razor).
6
+ * Client instances are cached by credential hash.
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+
11
+ const GRAPH_API_BASE = "https://graph.facebook.com/v21.0";
12
+ const CLIENT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
13
+
14
+ export interface Credentials {
15
+ accessToken: string;
16
+ pageId: string;
17
+ }
18
+
19
+ interface CachedClient {
20
+ client: FacebookClient;
21
+ createdAt: number;
22
+ }
23
+
24
+ const clientCache = new Map<string, CachedClient>();
25
+
26
+ function credentialHash(creds: Credentials): string {
27
+ const raw = `${creds.accessToken}:${creds.pageId}`;
28
+ return createHash("sha256").update(raw).digest("hex").slice(0, 16);
29
+ }
30
+
31
+ /**
32
+ * Get or create a cached Facebook client.
33
+ */
34
+ export function createClient(creds: Credentials): FacebookClient {
35
+ const key = credentialHash(creds);
36
+ const now = Date.now();
37
+
38
+ // Evict stale entries on every call
39
+ for (const [k, v] of clientCache) {
40
+ if (now - v.createdAt >= CLIENT_TTL_MS) {
41
+ clientCache.delete(k);
42
+ }
43
+ }
44
+
45
+ const cached = clientCache.get(key);
46
+ if (cached) return cached.client;
47
+
48
+ const client = new FacebookClient(creds);
49
+ clientCache.set(key, { client, createdAt: now });
50
+ return client;
51
+ }
52
+
53
+ /**
54
+ * Graph API error format from Facebook.
55
+ */
56
+ export interface GraphApiError {
57
+ error: {
58
+ message: string;
59
+ type: string;
60
+ code: number;
61
+ error_subcode?: number;
62
+ fbtrace_id?: string;
63
+ };
64
+ }
65
+
66
+ function isGraphApiError(body: unknown): body is GraphApiError {
67
+ return (
68
+ typeof body === "object" &&
69
+ body !== null &&
70
+ "error" in body &&
71
+ typeof (body as GraphApiError).error === "object" &&
72
+ typeof (body as GraphApiError).error.message === "string"
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Error thrown for Graph API failures. Carries structured error data
78
+ * for suggestAction() to inspect.
79
+ */
80
+ export class FacebookApiError extends Error {
81
+ readonly status: number;
82
+ readonly code: number;
83
+ readonly errorSubcode?: number;
84
+ readonly errorType: string;
85
+ readonly retryAfter?: number;
86
+
87
+ constructor(
88
+ status: number,
89
+ apiError: GraphApiError["error"],
90
+ retryAfter?: number,
91
+ ) {
92
+ super(apiError.message);
93
+ this.name = "FacebookApiError";
94
+ this.status = status;
95
+ this.code = apiError.code;
96
+ this.errorSubcode = apiError.error_subcode;
97
+ this.errorType = apiError.type;
98
+ this.retryAfter = retryAfter;
99
+ }
100
+ }
101
+
102
+ export class FacebookClient {
103
+ readonly accessToken: string;
104
+ readonly pageId: string;
105
+
106
+ constructor(creds: Credentials) {
107
+ this.accessToken = creds.accessToken;
108
+ this.pageId = creds.pageId;
109
+ }
110
+
111
+ /**
112
+ * Make a GET request to the Graph API.
113
+ */
114
+ async get<T = unknown>(
115
+ path: string,
116
+ params?: Record<string, string>,
117
+ ): Promise<T> {
118
+ const url = new URL(`${GRAPH_API_BASE}${path}`);
119
+ url.searchParams.set("access_token", this.accessToken);
120
+ if (params) {
121
+ for (const [key, value] of Object.entries(params)) {
122
+ url.searchParams.set(key, value);
123
+ }
124
+ }
125
+
126
+ const res = await fetch(url, {
127
+ signal: AbortSignal.timeout(30_000),
128
+ });
129
+
130
+ return this.handleResponse<T>(res);
131
+ }
132
+
133
+ /**
134
+ * Make a POST request to the Graph API.
135
+ */
136
+ async post<T = unknown>(
137
+ path: string,
138
+ body?: Record<string, unknown>,
139
+ ): Promise<T> {
140
+ const url = new URL(`${GRAPH_API_BASE}${path}`);
141
+ url.searchParams.set("access_token", this.accessToken);
142
+
143
+ const res = await fetch(url, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: body ? JSON.stringify(body) : undefined,
147
+ signal: AbortSignal.timeout(30_000),
148
+ });
149
+
150
+ return this.handleResponse<T>(res);
151
+ }
152
+
153
+ /**
154
+ * Make a DELETE request to the Graph API.
155
+ */
156
+ async delete<T = unknown>(path: string): Promise<T> {
157
+ const url = new URL(`${GRAPH_API_BASE}${path}`);
158
+ url.searchParams.set("access_token", this.accessToken);
159
+
160
+ const res = await fetch(url, {
161
+ method: "DELETE",
162
+ signal: AbortSignal.timeout(30_000),
163
+ });
164
+
165
+ return this.handleResponse<T>(res);
166
+ }
167
+
168
+ private async handleResponse<T>(res: Response): Promise<T> {
169
+ let body: unknown;
170
+ try {
171
+ body = await res.json();
172
+ } catch {
173
+ // Non-JSON response (HTML error page during outage, empty body, etc.)
174
+ throw new FacebookApiError(res.status, {
175
+ message: `HTTP ${res.status}: Response is not valid JSON (likely an outage or proxy error)`,
176
+ type: "ParseError",
177
+ code: res.status,
178
+ });
179
+ }
180
+
181
+ if (!res.ok) {
182
+ const retryAfterHeader = res.headers.get("Retry-After");
183
+ const seconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
184
+ const retryAfter = !isNaN(seconds) ? seconds : undefined;
185
+
186
+ if (isGraphApiError(body)) {
187
+ throw new FacebookApiError(res.status, body.error, retryAfter);
188
+ }
189
+
190
+ // Non-standard error response — wrap in a generic error
191
+ throw new FacebookApiError(
192
+ res.status,
193
+ {
194
+ message: `HTTP ${res.status}: ${JSON.stringify(body)}`,
195
+ type: "UnknownError",
196
+ code: res.status,
197
+ },
198
+ retryAfter,
199
+ );
200
+ }
201
+
202
+ return body as T;
203
+ }
204
+ }