@fewchurs/next 1.0.0 → 1.1.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.
package/LICENSE CHANGED
@@ -38,4 +38,4 @@ The source code is published for transparency only. It is not open source.
38
38
  OF OR OTHER DEALINGS IN THE SDK.
39
39
 
40
40
  Your use of the Fewchurs service itself is governed separately by your
41
- agreement with Fewchurs (https://www.fewchurs.shop).
41
+ agreement with Fewchurs (https://www.fewchurs.com).
package/README.md CHANGED
@@ -26,9 +26,14 @@ export default function Page() {
26
26
  }
27
27
  ```
28
28
 
29
+ Add your domain to your app in the Fewchurs dashboard. The API key only works there, and
30
+ `localhost` always works while you build. The package reads the domain from each request. Set
31
+ `FEWCHURS_APP_ID=yourcompany.com` when your site runs on another address, such as a preview
32
+ deployment.
33
+
29
34
  - `<FewchursBoard initialFeatureId="…">` — opens the board on one request, rendered on the
30
35
  server, for a link straight to it.
31
- - `createHandler({ apiKey, baseUrl, apiPath, deviceId })` — the proxy, mounted wherever you like.
36
+ - `createHandler({ apiKey, baseUrl, appId, apiPath, deviceId })` — the proxy, mounted wherever you like.
32
37
  - `@fewchurs/next/actions` — `submitFeatureAction`, `voteAction`, `commentAction`, `followAction`
33
38
  for boards built from plain forms.
34
39
  - `@fewchurs/next/identity` — the `httpOnly` device-id cookie helpers.
package/dist/actions.js CHANGED
@@ -33,6 +33,7 @@ var import_core3 = require("@fewchurs/react/core");
33
33
 
34
34
  // src/config.ts
35
35
  var import_server_only = require("server-only");
36
+ var import_headers = require("next/headers");
36
37
  var import_core = require("@fewchurs/react/core");
37
38
  function resolveApiKey(apiKey) {
38
39
  return apiKey ?? process.env.FEWCHURS_API_KEY;
@@ -40,7 +41,20 @@ function resolveApiKey(apiKey) {
40
41
  function resolveBaseUrl(baseUrl) {
41
42
  return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? import_core.DEFAULT_BASE_URL;
42
43
  }
43
- function serverClient(deviceId, config = {}) {
44
+ function configuredAppId(appId) {
45
+ return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || void 0;
46
+ }
47
+ function requestHostname(requestHeaders) {
48
+ const forwarded = requestHeaders.get("x-forwarded-host")?.split(",")[0]?.trim();
49
+ const host = forwarded || requestHeaders.get("host")?.trim();
50
+ if (!host) return void 0;
51
+ try {
52
+ return new URL(`http://${host}`).hostname;
53
+ } catch {
54
+ return void 0;
55
+ }
56
+ }
57
+ async function serverClient(deviceId, config = {}) {
44
58
  const apiKey = resolveApiKey(config.apiKey);
45
59
  if (!apiKey) {
46
60
  throw new Error(
@@ -51,17 +65,19 @@ function serverClient(deviceId, config = {}) {
51
65
  apiKey,
52
66
  baseUrl: resolveBaseUrl(config.baseUrl),
53
67
  deviceId,
54
- language: config.language
68
+ language: config.language,
69
+ // Only read the request when needed, so a page with a set domain can still render statically.
70
+ appId: configuredAppId(config.appId) ?? requestHostname(await (0, import_headers.headers)())
55
71
  });
56
72
  }
57
73
 
58
74
  // src/identity.ts
59
75
  var import_server_only2 = require("server-only");
60
- var import_headers = require("next/headers");
76
+ var import_headers2 = require("next/headers");
61
77
  var import_core2 = require("@fewchurs/react/core");
62
78
 
63
79
  // src/version.ts
64
- var SDK_VERSION = "1.0.0";
80
+ var SDK_VERSION = "1.1.0";
65
81
 
66
82
  // src/constants.ts
67
83
  var DEVICE_ID_COOKIE = "fewchurs_did";
@@ -70,7 +86,7 @@ var SDK_HEADER = `next/${SDK_VERSION}`;
70
86
 
71
87
  // src/identity.ts
72
88
  async function readDeviceId() {
73
- const store = await (0, import_headers.cookies)();
89
+ const store = await (0, import_headers2.cookies)();
74
90
  return store.get(DEVICE_ID_COOKIE)?.value ?? null;
75
91
  }
76
92
  async function resolveDeviceId(custom) {
@@ -80,7 +96,7 @@ async function resolveDeviceId(custom) {
80
96
 
81
97
  // src/actions.ts
82
98
  async function submitFeatureAction(formData) {
83
- const client = serverClient(await resolveDeviceId());
99
+ const client = await serverClient(await resolveDeviceId());
84
100
  const result = await client.submitFeature({
85
101
  title: String(formData.get("title") ?? ""),
86
102
  description: String(formData.get("description") ?? ""),
@@ -92,13 +108,13 @@ async function submitFeatureAction(formData) {
92
108
  async function voteAction(formData) {
93
109
  const featureId = String(formData.get("featureId") ?? "");
94
110
  const hasVoted = formData.get("hasVoted") === "true";
95
- const client = serverClient(await resolveDeviceId());
111
+ const client = await serverClient(await resolveDeviceId());
96
112
  const result = hasVoted ? await client.unvote(featureId) : await client.vote(featureId);
97
113
  revalidate(formData);
98
114
  return result.ok ? { ok: true } : { ok: false, message: (0, import_core3.userMessage)(result.error) };
99
115
  }
100
116
  async function commentAction(formData) {
101
- const client = serverClient(await resolveDeviceId());
117
+ const client = await serverClient(await resolveDeviceId());
102
118
  const result = await client.createComment({
103
119
  featureId: String(formData.get("featureId") ?? ""),
104
120
  text: String(formData.get("text") ?? ""),
@@ -110,7 +126,7 @@ async function commentAction(formData) {
110
126
  async function followAction(formData) {
111
127
  const featureId = String(formData.get("featureId") ?? "");
112
128
  const email = optional(formData.get("email"));
113
- const client = serverClient(await resolveDeviceId());
129
+ const client = await serverClient(await resolveDeviceId());
114
130
  const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);
115
131
  revalidate(formData);
116
132
  return result.ok ? { ok: true } : { ok: false, message: (0, import_core3.userMessage)(result.error) };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/actions.ts","../src/config.ts","../src/identity.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { userMessage } from \"@fewchurs/react/core\";\nimport { serverClient } from \"./config\";\nimport { resolveDeviceId } from \"./identity\";\n\nexport interface ActionResult {\n ok: boolean;\n message?: string;\n}\n\n/**\n * Server actions for boards built with plain forms — the client board talks to the route\n * handler instead. Pass `path` when the page should re-render with the new data.\n */\nexport async function submitFeatureAction(formData: FormData): Promise<ActionResult> {\n const client = serverClient(await resolveDeviceId());\n const result = await client.submitFeature({\n title: String(formData.get(\"title\") ?? \"\"),\n description: String(formData.get(\"description\") ?? \"\"),\n email: optional(formData.get(\"email\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function voteAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const hasVoted = formData.get(\"hasVoted\") === \"true\";\n const client = serverClient(await resolveDeviceId());\n const result = hasVoted ? await client.unvote(featureId) : await client.vote(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function commentAction(formData: FormData): Promise<ActionResult> {\n const client = serverClient(await resolveDeviceId());\n const result = await client.createComment({\n featureId: String(formData.get(\"featureId\") ?? \"\"),\n text: String(formData.get(\"text\") ?? \"\"),\n authorName: optional(formData.get(\"authorName\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function followAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const email = optional(formData.get(\"email\"));\n const client = serverClient(await resolveDeviceId());\n const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nfunction optional(value: FormDataEntryValue | null): string | undefined {\n const text = typeof value === \"string\" ? value.trim() : \"\";\n return text.length > 0 ? text : undefined;\n}\n\nfunction revalidate(formData: FormData): void {\n const path = formData.get(\"path\");\n if (typeof path === \"string\" && path.startsWith(\"/\")) revalidatePath(path);\n}\n","import \"server-only\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\nexport function serverClient(deviceId: string | null, config: ServerConfig = {}): FewchursClient {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n });\n}\n","import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.0.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":"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","names":["import_core","import_server_only","import_core"]}
1
+ {"version":3,"sources":["../src/actions.ts","../src/config.ts","../src/identity.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { userMessage } from \"@fewchurs/react/core\";\nimport { serverClient } from \"./config\";\nimport { resolveDeviceId } from \"./identity\";\n\nexport interface ActionResult {\n ok: boolean;\n message?: string;\n}\n\n/**\n * Server actions for boards built with plain forms — the client board talks to the route\n * handler instead. Pass `path` when the page should re-render with the new data.\n */\nexport async function submitFeatureAction(formData: FormData): Promise<ActionResult> {\n const client = await serverClient(await resolveDeviceId());\n const result = await client.submitFeature({\n title: String(formData.get(\"title\") ?? \"\"),\n description: String(formData.get(\"description\") ?? \"\"),\n email: optional(formData.get(\"email\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function voteAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const hasVoted = formData.get(\"hasVoted\") === \"true\";\n const client = await serverClient(await resolveDeviceId());\n const result = hasVoted ? await client.unvote(featureId) : await client.vote(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function commentAction(formData: FormData): Promise<ActionResult> {\n const client = await serverClient(await resolveDeviceId());\n const result = await client.createComment({\n featureId: String(formData.get(\"featureId\") ?? \"\"),\n text: String(formData.get(\"text\") ?? \"\"),\n authorName: optional(formData.get(\"authorName\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function followAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const email = optional(formData.get(\"email\"));\n const client = await serverClient(await resolveDeviceId());\n const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nfunction optional(value: FormDataEntryValue | null): string | undefined {\n const text = typeof value === \"string\" ? value.trim() : \"\";\n return text.length > 0 ? text : undefined;\n}\n\nfunction revalidate(formData: FormData): void {\n const path = formData.get(\"path\");\n if (typeof path === \"string\" && path.startsWith(\"/\")) revalidatePath(path);\n}\n","import \"server-only\";\nimport { headers } from \"next/headers\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\n/** A domain set by the developer, which replaces the one read from the request. */\nexport function configuredAppId(appId?: string): string | undefined {\n return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || undefined;\n}\n\n/** The site's host name without a port. A reverse proxy reports the public one in `x-forwarded-host`. */\nexport function requestHostname(requestHeaders: Headers): string | undefined {\n const forwarded = requestHeaders.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n const host = forwarded || requestHeaders.get(\"host\")?.trim();\n if (!host) return undefined;\n try {\n return new URL(`http://${host}`).hostname;\n } catch {\n return undefined;\n }\n}\n\nexport async function serverClient(\n deviceId: string | null,\n config: ServerConfig = {},\n): Promise<FewchursClient> {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n // Only read the request when needed, so a page with a set domain can still render statically.\n appId: configuredAppId(config.appId) ?? requestHostname(await headers()),\n });\n}\n","import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.1.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":"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","names":["import_core","import_server_only","import_headers","import_core"]}
package/dist/actions.mjs CHANGED
@@ -7,6 +7,7 @@ import { userMessage } from "@fewchurs/react/core";
7
7
 
8
8
  // src/config.ts
9
9
  import "server-only";
10
+ import { headers } from "next/headers";
10
11
  import { createFewchursClient, DEFAULT_BASE_URL } from "@fewchurs/react/core";
11
12
  function resolveApiKey(apiKey) {
12
13
  return apiKey ?? process.env.FEWCHURS_API_KEY;
@@ -14,7 +15,20 @@ function resolveApiKey(apiKey) {
14
15
  function resolveBaseUrl(baseUrl) {
15
16
  return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;
16
17
  }
17
- function serverClient(deviceId, config = {}) {
18
+ function configuredAppId(appId) {
19
+ return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || void 0;
20
+ }
21
+ function requestHostname(requestHeaders) {
22
+ const forwarded = requestHeaders.get("x-forwarded-host")?.split(",")[0]?.trim();
23
+ const host = forwarded || requestHeaders.get("host")?.trim();
24
+ if (!host) return void 0;
25
+ try {
26
+ return new URL(`http://${host}`).hostname;
27
+ } catch {
28
+ return void 0;
29
+ }
30
+ }
31
+ async function serverClient(deviceId, config = {}) {
18
32
  const apiKey = resolveApiKey(config.apiKey);
19
33
  if (!apiKey) {
20
34
  throw new Error(
@@ -25,7 +39,9 @@ function serverClient(deviceId, config = {}) {
25
39
  apiKey,
26
40
  baseUrl: resolveBaseUrl(config.baseUrl),
27
41
  deviceId,
28
- language: config.language
42
+ language: config.language,
43
+ // Only read the request when needed, so a page with a set domain can still render statically.
44
+ appId: configuredAppId(config.appId) ?? requestHostname(await headers())
29
45
  });
30
46
  }
31
47
 
@@ -35,7 +51,7 @@ import { cookies } from "next/headers";
35
51
  import { createDeviceId } from "@fewchurs/react/core";
36
52
 
37
53
  // src/version.ts
38
- var SDK_VERSION = "1.0.0";
54
+ var SDK_VERSION = "1.1.0";
39
55
 
40
56
  // src/constants.ts
41
57
  var DEVICE_ID_COOKIE = "fewchurs_did";
@@ -54,7 +70,7 @@ async function resolveDeviceId(custom) {
54
70
 
55
71
  // src/actions.ts
56
72
  async function submitFeatureAction(formData) {
57
- const client = serverClient(await resolveDeviceId());
73
+ const client = await serverClient(await resolveDeviceId());
58
74
  const result = await client.submitFeature({
59
75
  title: String(formData.get("title") ?? ""),
60
76
  description: String(formData.get("description") ?? ""),
@@ -66,13 +82,13 @@ async function submitFeatureAction(formData) {
66
82
  async function voteAction(formData) {
67
83
  const featureId = String(formData.get("featureId") ?? "");
68
84
  const hasVoted = formData.get("hasVoted") === "true";
69
- const client = serverClient(await resolveDeviceId());
85
+ const client = await serverClient(await resolveDeviceId());
70
86
  const result = hasVoted ? await client.unvote(featureId) : await client.vote(featureId);
71
87
  revalidate(formData);
72
88
  return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };
73
89
  }
74
90
  async function commentAction(formData) {
75
- const client = serverClient(await resolveDeviceId());
91
+ const client = await serverClient(await resolveDeviceId());
76
92
  const result = await client.createComment({
77
93
  featureId: String(formData.get("featureId") ?? ""),
78
94
  text: String(formData.get("text") ?? ""),
@@ -84,7 +100,7 @@ async function commentAction(formData) {
84
100
  async function followAction(formData) {
85
101
  const featureId = String(formData.get("featureId") ?? "");
86
102
  const email = optional(formData.get("email"));
87
- const client = serverClient(await resolveDeviceId());
103
+ const client = await serverClient(await resolveDeviceId());
88
104
  const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);
89
105
  revalidate(formData);
90
106
  return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/actions.ts","../src/config.ts","../src/identity.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { userMessage } from \"@fewchurs/react/core\";\nimport { serverClient } from \"./config\";\nimport { resolveDeviceId } from \"./identity\";\n\nexport interface ActionResult {\n ok: boolean;\n message?: string;\n}\n\n/**\n * Server actions for boards built with plain forms — the client board talks to the route\n * handler instead. Pass `path` when the page should re-render with the new data.\n */\nexport async function submitFeatureAction(formData: FormData): Promise<ActionResult> {\n const client = serverClient(await resolveDeviceId());\n const result = await client.submitFeature({\n title: String(formData.get(\"title\") ?? \"\"),\n description: String(formData.get(\"description\") ?? \"\"),\n email: optional(formData.get(\"email\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function voteAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const hasVoted = formData.get(\"hasVoted\") === \"true\";\n const client = serverClient(await resolveDeviceId());\n const result = hasVoted ? await client.unvote(featureId) : await client.vote(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function commentAction(formData: FormData): Promise<ActionResult> {\n const client = serverClient(await resolveDeviceId());\n const result = await client.createComment({\n featureId: String(formData.get(\"featureId\") ?? \"\"),\n text: String(formData.get(\"text\") ?? \"\"),\n authorName: optional(formData.get(\"authorName\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function followAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const email = optional(formData.get(\"email\"));\n const client = serverClient(await resolveDeviceId());\n const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nfunction optional(value: FormDataEntryValue | null): string | undefined {\n const text = typeof value === \"string\" ? value.trim() : \"\";\n return text.length > 0 ? text : undefined;\n}\n\nfunction revalidate(formData: FormData): void {\n const path = formData.get(\"path\");\n if (typeof path === \"string\" && path.startsWith(\"/\")) revalidatePath(path);\n}\n","import \"server-only\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\nexport function serverClient(deviceId: string | null, config: ServerConfig = {}): FewchursClient {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n });\n}\n","import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.0.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/actions.ts","../src/config.ts","../src/identity.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { userMessage } from \"@fewchurs/react/core\";\nimport { serverClient } from \"./config\";\nimport { resolveDeviceId } from \"./identity\";\n\nexport interface ActionResult {\n ok: boolean;\n message?: string;\n}\n\n/**\n * Server actions for boards built with plain forms — the client board talks to the route\n * handler instead. Pass `path` when the page should re-render with the new data.\n */\nexport async function submitFeatureAction(formData: FormData): Promise<ActionResult> {\n const client = await serverClient(await resolveDeviceId());\n const result = await client.submitFeature({\n title: String(formData.get(\"title\") ?? \"\"),\n description: String(formData.get(\"description\") ?? \"\"),\n email: optional(formData.get(\"email\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function voteAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const hasVoted = formData.get(\"hasVoted\") === \"true\";\n const client = await serverClient(await resolveDeviceId());\n const result = hasVoted ? await client.unvote(featureId) : await client.vote(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function commentAction(formData: FormData): Promise<ActionResult> {\n const client = await serverClient(await resolveDeviceId());\n const result = await client.createComment({\n featureId: String(formData.get(\"featureId\") ?? \"\"),\n text: String(formData.get(\"text\") ?? \"\"),\n authorName: optional(formData.get(\"authorName\")),\n });\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nexport async function followAction(formData: FormData): Promise<ActionResult> {\n const featureId = String(formData.get(\"featureId\") ?? \"\");\n const email = optional(formData.get(\"email\"));\n const client = await serverClient(await resolveDeviceId());\n const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);\n\n revalidate(formData);\n return result.ok ? { ok: true } : { ok: false, message: userMessage(result.error) };\n}\n\nfunction optional(value: FormDataEntryValue | null): string | undefined {\n const text = typeof value === \"string\" ? value.trim() : \"\";\n return text.length > 0 ? text : undefined;\n}\n\nfunction revalidate(formData: FormData): void {\n const path = formData.get(\"path\");\n if (typeof path === \"string\" && path.startsWith(\"/\")) revalidatePath(path);\n}\n","import \"server-only\";\nimport { headers } from \"next/headers\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\n/** A domain set by the developer, which replaces the one read from the request. */\nexport function configuredAppId(appId?: string): string | undefined {\n return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || undefined;\n}\n\n/** The site's host name without a port. A reverse proxy reports the public one in `x-forwarded-host`. */\nexport function requestHostname(requestHeaders: Headers): string | undefined {\n const forwarded = requestHeaders.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n const host = forwarded || requestHeaders.get(\"host\")?.trim();\n if (!host) return undefined;\n try {\n return new URL(`http://${host}`).hostname;\n } catch {\n return undefined;\n }\n}\n\nexport async function serverClient(\n deviceId: string | null,\n config: ServerConfig = {},\n): Promise<FewchursClient> {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n // Only read the request when needed, so a page with a set domain can still render statically.\n appId: configuredAppId(config.appId) ?? requestHostname(await headers()),\n });\n}\n","import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.1.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  DEVICE_ID_COOKIE,
3
3
  DEVICE_ID_MAX_AGE
4
- } from "./chunk-SSHXXI5K.mjs";
4
+ } from "./chunk-L57II7SI.mjs";
5
5
 
6
6
  // src/identity.ts
7
7
  import "server-only";
@@ -35,4 +35,4 @@ export {
35
35
  resolveDeviceId,
36
36
  deviceIdCookie
37
37
  };
38
- //# sourceMappingURL=chunk-P6QQPQVC.mjs.map
38
+ //# sourceMappingURL=chunk-DM357WK6.mjs.map
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var SDK_VERSION = "1.0.0";
2
+ var SDK_VERSION = "1.1.0";
3
3
 
4
4
  // src/constants.ts
5
5
  var DEVICE_ID_COOKIE = "fewchurs_did";
@@ -11,4 +11,4 @@ export {
11
11
  DEVICE_ID_MAX_AGE,
12
12
  SDK_HEADER
13
13
  };
14
- //# sourceMappingURL=chunk-SSHXXI5K.mjs.map
14
+ //# sourceMappingURL=chunk-L57II7SI.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/version.ts","../src/constants.ts"],"sourcesContent":["// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.0.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":";AACO,IAAM,cAAc;;;ACCpB,IAAM,mBAAmB;AACzB,IAAM,oBAAoB,KAAK,KAAK,KAAK;AAEzC,IAAM,aAAa,QAAQ,WAAW;","names":[]}
1
+ {"version":3,"sources":["../src/version.ts","../src/constants.ts"],"sourcesContent":["// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.1.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":";AACO,IAAM,cAAc;;;ACCpB,IAAM,mBAAmB;AACzB,IAAM,oBAAoB,KAAK,KAAK,KAAK;AAEzC,IAAM,aAAa,QAAQ,WAAW;","names":[]}
@@ -2,7 +2,7 @@ import {
2
2
  DEVICE_ID_COOKIE,
3
3
  DEVICE_ID_MAX_AGE,
4
4
  SDK_HEADER
5
- } from "./chunk-SSHXXI5K.mjs";
5
+ } from "./chunk-L57II7SI.mjs";
6
6
 
7
7
  // src/handler.ts
8
8
  import "server-only";
@@ -10,6 +10,7 @@ import { createDeviceId } from "@fewchurs/react/core";
10
10
 
11
11
  // src/config.ts
12
12
  import "server-only";
13
+ import { headers } from "next/headers";
13
14
  import { createFewchursClient, DEFAULT_BASE_URL } from "@fewchurs/react/core";
14
15
  var DEFAULT_API_PATH = "/api/fewchurs";
15
16
  function resolveApiKey(apiKey) {
@@ -21,7 +22,20 @@ function resolveBaseUrl(baseUrl) {
21
22
  function resolveApiPath(apiPath) {
22
23
  return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\/$/, "");
23
24
  }
24
- function serverClient(deviceId, config = {}) {
25
+ function configuredAppId(appId) {
26
+ return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || void 0;
27
+ }
28
+ function requestHostname(requestHeaders) {
29
+ const forwarded = requestHeaders.get("x-forwarded-host")?.split(",")[0]?.trim();
30
+ const host = forwarded || requestHeaders.get("host")?.trim();
31
+ if (!host) return void 0;
32
+ try {
33
+ return new URL(`http://${host}`).hostname;
34
+ } catch {
35
+ return void 0;
36
+ }
37
+ }
38
+ async function serverClient(deviceId, config = {}) {
25
39
  const apiKey = resolveApiKey(config.apiKey);
26
40
  if (!apiKey) {
27
41
  throw new Error(
@@ -32,7 +46,9 @@ function serverClient(deviceId, config = {}) {
32
46
  apiKey,
33
47
  baseUrl: resolveBaseUrl(config.baseUrl),
34
48
  deviceId,
35
- language: config.language
49
+ language: config.language,
50
+ // Only read the request when needed, so a page with a set domain can still render statically.
51
+ appId: configuredAppId(config.appId) ?? requestHostname(await headers())
36
52
  });
37
53
  }
38
54
 
@@ -67,23 +83,26 @@ function createHandler(options = {}) {
67
83
  const custom = options.deviceId ? await options.deviceId() : null;
68
84
  const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? createDeviceId();
69
85
  const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;
70
- const headers = {
86
+ const headers2 = {
71
87
  Authorization: `Bearer ${apiKey}`,
72
88
  "X-Device-Id": deviceId,
73
- "X-Fewchurs-Sdk": request.headers.get("x-fewchurs-sdk") ?? SDK_HEADER,
89
+ "X-Fewchurs-Sdk": SDK_HEADER,
90
+ "X-Fewchurs-App-Id": configuredAppId(options.appId) ?? requestHostname(request.headers) ?? new URL(request.url).hostname,
74
91
  Accept: "application/json"
75
92
  };
76
93
  const language = request.headers.get("accept-language");
77
- if (language) headers["Accept-Language"] = language;
94
+ if (language) headers2["Accept-Language"] = language;
78
95
  const body = request.method === "GET" ? void 0 : await request.text();
79
- if (body) headers["Content-Type"] = "application/json";
96
+ if (body) headers2["Content-Type"] = "application/json";
80
97
  let upstream;
81
98
  try {
82
- upstream = await fetch(target, { method: request.method, headers, body, cache: "no-store" });
99
+ upstream = await fetch(target, { method: request.method, headers: headers2, body, cache: "no-store" });
83
100
  } catch {
84
101
  return json({ error: { code: "internal", message: "Could not reach Fewchurs." } }, 502);
85
102
  }
86
- const response = new Response(await upstream.text(), {
103
+ const text = await upstream.text();
104
+ if (upstream.status === 403) logRefusal(text);
105
+ const response = new Response(text, {
87
106
  status: upstream.status,
88
107
  headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
89
108
  });
@@ -98,6 +117,17 @@ var defaultHandler = createHandler();
98
117
  var GET = defaultHandler.GET;
99
118
  var POST = defaultHandler.POST;
100
119
  var DELETE = defaultHandler.DELETE;
120
+ var loggedRefusal = false;
121
+ function logRefusal(body) {
122
+ if (loggedRefusal) return;
123
+ try {
124
+ const error = JSON.parse(body).error;
125
+ if (error?.code !== "wrong_app") return;
126
+ loggedRefusal = true;
127
+ console.error(`[fewchurs] ${error.message}`);
128
+ } catch {
129
+ }
130
+ }
101
131
  async function routePath(request, context, apiPath) {
102
132
  const params = await context?.params;
103
133
  if (params?.route?.length) return params.route.map(encodeURIComponent).join("/");
@@ -142,4 +172,4 @@ export {
142
172
  POST,
143
173
  DELETE
144
174
  };
145
- //# sourceMappingURL=chunk-VY4D2Z6L.mjs.map
175
+ //# sourceMappingURL=chunk-ZRYXVCLB.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/handler.ts","../src/config.ts"],"sourcesContent":["import \"server-only\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport {\n configuredAppId,\n requestHostname,\n resolveApiKey,\n resolveApiPath,\n resolveBaseUrl,\n} from \"./config\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE, SDK_HEADER } from \"./constants\";\nimport type { DeviceIdResolver } from \"./identity\";\n\nexport interface HandlerOptions {\n apiKey?: string;\n baseUrl?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n /** Where the handler is mounted, when it is not `/api/fewchurs`. */\n apiPath?: string;\n /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */\n deviceId?: DeviceIdResolver;\n}\n\ntype RouteParams = { route?: string[] };\n\n/** What Next passes: a Promise in 15, a plain object in 14. Both are awaited the same way. */\ntype IncomingContext = { params?: RouteParams | Promise<RouteParams> };\n\n// Next 15's route check wants a required second argument whose `params` is a Promise. The\n// handler itself takes `IncomingContext`, so Next 14's plain object still works at runtime.\ntype RouteContext = { params: Promise<RouteParams> };\n\nexport type RouteHandler = (request: Request, context: RouteContext) => Promise<Response>;\n\n/** Only the endpoints the board needs: everything else is a 404, not a proxy to the API. */\nconst ALLOWED: Array<{ method: string; pattern: RegExp }> = [\n { method: \"GET\", pattern: /^config$/ },\n { method: \"GET\", pattern: /^features$/ },\n { method: \"POST\", pattern: /^features$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+\\/comments$/ },\n { method: \"POST\", pattern: /^comments$/ },\n];\n\nconst DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport function createHandler(options: HandlerOptions = {}): {\n GET: RouteHandler;\n POST: RouteHandler;\n DELETE: RouteHandler;\n} {\n async function handle(request: Request, context?: IncomingContext): Promise<Response> {\n const path = await routePath(request, context, resolveApiPath(options.apiPath));\n\n if (!ALLOWED.some((route) => route.method === request.method && route.pattern.test(path))) {\n return json({ error: { code: \"not_found\", message: \"Unknown Fewchurs route.\" } }, 404);\n }\n\n const apiKey = resolveApiKey(options.apiKey);\n if (!apiKey) {\n console.error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY, or pass apiKey to createHandler().\",\n );\n return json({ error: { code: \"internal\", message: \"Fewchurs is not configured.\" } }, 500);\n }\n\n const cookieValue = readCookie(request, DEVICE_ID_COOKIE);\n const custom = options.deviceId ? await options.deviceId() : null;\n const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? createDeviceId();\n\n const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${apiKey}`,\n \"X-Device-Id\": deviceId,\n \"X-Fewchurs-Sdk\": SDK_HEADER,\n \"X-Fewchurs-App-Id\":\n configuredAppId(options.appId) ??\n requestHostname(request.headers) ??\n new URL(request.url).hostname,\n Accept: \"application/json\",\n };\n const language = request.headers.get(\"accept-language\");\n if (language) headers[\"Accept-Language\"] = language;\n\n const body = request.method === \"GET\" ? undefined : await request.text();\n if (body) headers[\"Content-Type\"] = \"application/json\";\n\n let upstream: Response;\n try {\n upstream = await fetch(target, { method: request.method, headers, body, cache: \"no-store\" });\n } catch {\n return json({ error: { code: \"internal\", message: \"Could not reach Fewchurs.\" } }, 502);\n }\n\n const text = await upstream.text();\n if (upstream.status === 403) logRefusal(text);\n\n const response = new Response(text, {\n status: upstream.status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n\n // A visitor keeps the same id across requests without the browser ever seeing the key,\n // and an httpOnly cookie means page scripts cannot read or change it either.\n if (!custom && deviceId !== cookieValue) {\n response.headers.append(\"Set-Cookie\", serializeCookie(deviceId));\n }\n\n return response;\n }\n\n return { GET: handle, POST: handle, DELETE: handle };\n}\n\nconst defaultHandler = createHandler();\n\nexport const GET = defaultHandler.GET;\nexport const POST = defaultHandler.POST;\nexport const DELETE = defaultHandler.DELETE;\n\nlet loggedRefusal = false;\n\n/** Visitors only see a safe message, so a live site's server log is where the domain gets named. */\nfunction logRefusal(body: string): void {\n if (loggedRefusal) return;\n try {\n const error = (JSON.parse(body) as { error?: { code?: string; message?: string } }).error;\n if (error?.code !== \"wrong_app\") return;\n loggedRefusal = true;\n console.error(`[fewchurs] ${error.message}`);\n } catch {\n // Not JSON, so it isn't a refusal from Fewchurs.\n }\n}\n\nasync function routePath(\n request: Request,\n context: IncomingContext | undefined,\n apiPath: string,\n): Promise<string> {\n const params = await context?.params;\n if (params?.route?.length) return params.route.map(encodeURIComponent).join(\"/\");\n\n const { pathname } = new URL(request.url);\n const index = pathname.indexOf(apiPath);\n return index === -1 ? \"\" : pathname.slice(index + apiPath.length).replace(/^\\//, \"\");\n}\n\n/**\n * The first request from a server-rendered board carries the id the page was rendered with,\n * so adopting it keeps the server's `hasVoted` answers and the browser's in agreement.\n */\nfunction claimedDeviceId(request: Request): string | null {\n const claimed = request.headers.get(\"x-device-id\");\n return claimed && DEVICE_ID_PATTERN.test(claimed) ? claimed : null;\n}\n\nfunction readCookie(request: Request, name: string): string | null {\n const header = request.headers.get(\"cookie\");\n if (!header) return null;\n const match = header.match(new RegExp(`(?:^|;\\\\s*)${name}=([^;]*)`));\n return match?.[1] ? decodeURIComponent(match[1]) : null;\n}\n\nfunction serializeCookie(deviceId: string): string {\n const parts = [\n `${DEVICE_ID_COOKIE}=${encodeURIComponent(deviceId)}`,\n \"Path=/\",\n `Max-Age=${DEVICE_ID_MAX_AGE}`,\n \"HttpOnly\",\n \"SameSite=Lax\",\n ];\n if (process.env.NODE_ENV === \"production\") parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nfunction json(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n}\n","import \"server-only\";\nimport { headers } from \"next/headers\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\n/** A domain set by the developer, which replaces the one read from the request. */\nexport function configuredAppId(appId?: string): string | undefined {\n return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || undefined;\n}\n\n/** The site's host name without a port. A reverse proxy reports the public one in `x-forwarded-host`. */\nexport function requestHostname(requestHeaders: Headers): string | undefined {\n const forwarded = requestHeaders.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n const host = forwarded || requestHeaders.get(\"host\")?.trim();\n if (!host) return undefined;\n try {\n return new URL(`http://${host}`).hostname;\n } catch {\n return undefined;\n }\n}\n\nexport async function serverClient(\n deviceId: string | null,\n config: ServerConfig = {},\n): Promise<FewchursClient> {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n // Only read the request when needed, so a page with a set domain can still render statically.\n appId: configuredAppId(config.appId) ?? requestHostname(await headers()),\n });\n}\n"],"mappings":"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","names":["headers"]}
@@ -4,6 +4,8 @@ import '@fewchurs/react/core';
4
4
  interface HandlerOptions {
5
5
  apiKey?: string;
6
6
  baseUrl?: string;
7
+ /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */
8
+ appId?: string;
7
9
  /** Where the handler is mounted, when it is not `/api/fewchurs`. */
8
10
  apiPath?: string;
9
11
  /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */
package/dist/handler.d.ts CHANGED
@@ -4,6 +4,8 @@ import '@fewchurs/react/core';
4
4
  interface HandlerOptions {
5
5
  apiKey?: string;
6
6
  baseUrl?: string;
7
+ /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */
8
+ appId?: string;
7
9
  /** Where the handler is mounted, when it is not `/api/fewchurs`. */
8
10
  apiPath?: string;
9
11
  /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */
package/dist/handler.js CHANGED
@@ -31,6 +31,7 @@ var import_core2 = require("@fewchurs/react/core");
31
31
 
32
32
  // src/config.ts
33
33
  var import_server_only = require("server-only");
34
+ var import_headers = require("next/headers");
34
35
  var import_core = require("@fewchurs/react/core");
35
36
  var DEFAULT_API_PATH = "/api/fewchurs";
36
37
  function resolveApiKey(apiKey) {
@@ -42,9 +43,22 @@ function resolveBaseUrl(baseUrl) {
42
43
  function resolveApiPath(apiPath) {
43
44
  return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\/$/, "");
44
45
  }
46
+ function configuredAppId(appId) {
47
+ return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || void 0;
48
+ }
49
+ function requestHostname(requestHeaders) {
50
+ const forwarded = requestHeaders.get("x-forwarded-host")?.split(",")[0]?.trim();
51
+ const host = forwarded || requestHeaders.get("host")?.trim();
52
+ if (!host) return void 0;
53
+ try {
54
+ return new URL(`http://${host}`).hostname;
55
+ } catch {
56
+ return void 0;
57
+ }
58
+ }
45
59
 
46
60
  // src/version.ts
47
- var SDK_VERSION = "1.0.0";
61
+ var SDK_VERSION = "1.1.0";
48
62
 
49
63
  // src/constants.ts
50
64
  var DEVICE_ID_COOKIE = "fewchurs_did";
@@ -82,23 +96,26 @@ function createHandler(options = {}) {
82
96
  const custom = options.deviceId ? await options.deviceId() : null;
83
97
  const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? (0, import_core2.createDeviceId)();
84
98
  const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;
85
- const headers = {
99
+ const headers2 = {
86
100
  Authorization: `Bearer ${apiKey}`,
87
101
  "X-Device-Id": deviceId,
88
- "X-Fewchurs-Sdk": request.headers.get("x-fewchurs-sdk") ?? SDK_HEADER,
102
+ "X-Fewchurs-Sdk": SDK_HEADER,
103
+ "X-Fewchurs-App-Id": configuredAppId(options.appId) ?? requestHostname(request.headers) ?? new URL(request.url).hostname,
89
104
  Accept: "application/json"
90
105
  };
91
106
  const language = request.headers.get("accept-language");
92
- if (language) headers["Accept-Language"] = language;
107
+ if (language) headers2["Accept-Language"] = language;
93
108
  const body = request.method === "GET" ? void 0 : await request.text();
94
- if (body) headers["Content-Type"] = "application/json";
109
+ if (body) headers2["Content-Type"] = "application/json";
95
110
  let upstream;
96
111
  try {
97
- upstream = await fetch(target, { method: request.method, headers, body, cache: "no-store" });
112
+ upstream = await fetch(target, { method: request.method, headers: headers2, body, cache: "no-store" });
98
113
  } catch {
99
114
  return json({ error: { code: "internal", message: "Could not reach Fewchurs." } }, 502);
100
115
  }
101
- const response = new Response(await upstream.text(), {
116
+ const text = await upstream.text();
117
+ if (upstream.status === 403) logRefusal(text);
118
+ const response = new Response(text, {
102
119
  status: upstream.status,
103
120
  headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
104
121
  });
@@ -113,6 +130,17 @@ var defaultHandler = createHandler();
113
130
  var GET = defaultHandler.GET;
114
131
  var POST = defaultHandler.POST;
115
132
  var DELETE = defaultHandler.DELETE;
133
+ var loggedRefusal = false;
134
+ function logRefusal(body) {
135
+ if (loggedRefusal) return;
136
+ try {
137
+ const error = JSON.parse(body).error;
138
+ if (error?.code !== "wrong_app") return;
139
+ loggedRefusal = true;
140
+ console.error(`[fewchurs] ${error.message}`);
141
+ } catch {
142
+ }
143
+ }
116
144
  async function routePath(request, context, apiPath) {
117
145
  const params = await context?.params;
118
146
  if (params?.route?.length) return params.route.map(encodeURIComponent).join("/");
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/handler.ts","../src/config.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["import \"server-only\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { resolveApiKey, resolveApiPath, resolveBaseUrl } from \"./config\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE, SDK_HEADER } from \"./constants\";\nimport type { DeviceIdResolver } from \"./identity\";\n\nexport interface HandlerOptions {\n apiKey?: string;\n baseUrl?: string;\n /** Where the handler is mounted, when it is not `/api/fewchurs`. */\n apiPath?: string;\n /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */\n deviceId?: DeviceIdResolver;\n}\n\ntype RouteParams = { route?: string[] };\n\n/** What Next passes: a Promise in 15, a plain object in 14. Both are awaited the same way. */\ntype IncomingContext = { params?: RouteParams | Promise<RouteParams> };\n\n// Next 15's route check wants a required second argument whose `params` is a Promise. The\n// handler itself takes `IncomingContext`, so Next 14's plain object still works at runtime.\ntype RouteContext = { params: Promise<RouteParams> };\n\nexport type RouteHandler = (request: Request, context: RouteContext) => Promise<Response>;\n\n/** Only the endpoints the board needs: everything else is a 404, not a proxy to the API. */\nconst ALLOWED: Array<{ method: string; pattern: RegExp }> = [\n { method: \"GET\", pattern: /^config$/ },\n { method: \"GET\", pattern: /^features$/ },\n { method: \"POST\", pattern: /^features$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+\\/comments$/ },\n { method: \"POST\", pattern: /^comments$/ },\n];\n\nconst DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport function createHandler(options: HandlerOptions = {}): {\n GET: RouteHandler;\n POST: RouteHandler;\n DELETE: RouteHandler;\n} {\n async function handle(request: Request, context?: IncomingContext): Promise<Response> {\n const path = await routePath(request, context, resolveApiPath(options.apiPath));\n\n if (!ALLOWED.some((route) => route.method === request.method && route.pattern.test(path))) {\n return json({ error: { code: \"not_found\", message: \"Unknown Fewchurs route.\" } }, 404);\n }\n\n const apiKey = resolveApiKey(options.apiKey);\n if (!apiKey) {\n console.error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY, or pass apiKey to createHandler().\",\n );\n return json({ error: { code: \"internal\", message: \"Fewchurs is not configured.\" } }, 500);\n }\n\n const cookieValue = readCookie(request, DEVICE_ID_COOKIE);\n const custom = options.deviceId ? await options.deviceId() : null;\n const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? createDeviceId();\n\n const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${apiKey}`,\n \"X-Device-Id\": deviceId,\n \"X-Fewchurs-Sdk\": request.headers.get(\"x-fewchurs-sdk\") ?? SDK_HEADER,\n Accept: \"application/json\",\n };\n const language = request.headers.get(\"accept-language\");\n if (language) headers[\"Accept-Language\"] = language;\n\n const body = request.method === \"GET\" ? undefined : await request.text();\n if (body) headers[\"Content-Type\"] = \"application/json\";\n\n let upstream: Response;\n try {\n upstream = await fetch(target, { method: request.method, headers, body, cache: \"no-store\" });\n } catch {\n return json({ error: { code: \"internal\", message: \"Could not reach Fewchurs.\" } }, 502);\n }\n\n const response = new Response(await upstream.text(), {\n status: upstream.status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n\n // A visitor keeps the same id across requests without the browser ever seeing the key,\n // and an httpOnly cookie means page scripts cannot read or change it either.\n if (!custom && deviceId !== cookieValue) {\n response.headers.append(\"Set-Cookie\", serializeCookie(deviceId));\n }\n\n return response;\n }\n\n return { GET: handle, POST: handle, DELETE: handle };\n}\n\nconst defaultHandler = createHandler();\n\nexport const GET = defaultHandler.GET;\nexport const POST = defaultHandler.POST;\nexport const DELETE = defaultHandler.DELETE;\n\nasync function routePath(\n request: Request,\n context: IncomingContext | undefined,\n apiPath: string,\n): Promise<string> {\n const params = await context?.params;\n if (params?.route?.length) return params.route.map(encodeURIComponent).join(\"/\");\n\n const { pathname } = new URL(request.url);\n const index = pathname.indexOf(apiPath);\n return index === -1 ? \"\" : pathname.slice(index + apiPath.length).replace(/^\\//, \"\");\n}\n\n/**\n * The first request from a server-rendered board carries the id the page was rendered with,\n * so adopting it keeps the server's `hasVoted` answers and the browser's in agreement.\n */\nfunction claimedDeviceId(request: Request): string | null {\n const claimed = request.headers.get(\"x-device-id\");\n return claimed && DEVICE_ID_PATTERN.test(claimed) ? claimed : null;\n}\n\nfunction readCookie(request: Request, name: string): string | null {\n const header = request.headers.get(\"cookie\");\n if (!header) return null;\n const match = header.match(new RegExp(`(?:^|;\\\\s*)${name}=([^;]*)`));\n return match?.[1] ? decodeURIComponent(match[1]) : null;\n}\n\nfunction serializeCookie(deviceId: string): string {\n const parts = [\n `${DEVICE_ID_COOKIE}=${encodeURIComponent(deviceId)}`,\n \"Path=/\",\n `Max-Age=${DEVICE_ID_MAX_AGE}`,\n \"HttpOnly\",\n \"SameSite=Lax\",\n ];\n if (process.env.NODE_ENV === \"production\") parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nfunction json(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n}\n","import \"server-only\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\nexport function serverClient(deviceId: string | null, config: ServerConfig = {}): FewchursClient {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n });\n}\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.0.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":"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","names":["import_server_only","import_core"]}
1
+ {"version":3,"sources":["../src/handler.ts","../src/config.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["import \"server-only\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport {\n configuredAppId,\n requestHostname,\n resolveApiKey,\n resolveApiPath,\n resolveBaseUrl,\n} from \"./config\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE, SDK_HEADER } from \"./constants\";\nimport type { DeviceIdResolver } from \"./identity\";\n\nexport interface HandlerOptions {\n apiKey?: string;\n baseUrl?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n /** Where the handler is mounted, when it is not `/api/fewchurs`. */\n apiPath?: string;\n /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */\n deviceId?: DeviceIdResolver;\n}\n\ntype RouteParams = { route?: string[] };\n\n/** What Next passes: a Promise in 15, a plain object in 14. Both are awaited the same way. */\ntype IncomingContext = { params?: RouteParams | Promise<RouteParams> };\n\n// Next 15's route check wants a required second argument whose `params` is a Promise. The\n// handler itself takes `IncomingContext`, so Next 14's plain object still works at runtime.\ntype RouteContext = { params: Promise<RouteParams> };\n\nexport type RouteHandler = (request: Request, context: RouteContext) => Promise<Response>;\n\n/** Only the endpoints the board needs: everything else is a 404, not a proxy to the API. */\nconst ALLOWED: Array<{ method: string; pattern: RegExp }> = [\n { method: \"GET\", pattern: /^config$/ },\n { method: \"GET\", pattern: /^features$/ },\n { method: \"POST\", pattern: /^features$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+\\/comments$/ },\n { method: \"POST\", pattern: /^comments$/ },\n];\n\nconst DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport function createHandler(options: HandlerOptions = {}): {\n GET: RouteHandler;\n POST: RouteHandler;\n DELETE: RouteHandler;\n} {\n async function handle(request: Request, context?: IncomingContext): Promise<Response> {\n const path = await routePath(request, context, resolveApiPath(options.apiPath));\n\n if (!ALLOWED.some((route) => route.method === request.method && route.pattern.test(path))) {\n return json({ error: { code: \"not_found\", message: \"Unknown Fewchurs route.\" } }, 404);\n }\n\n const apiKey = resolveApiKey(options.apiKey);\n if (!apiKey) {\n console.error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY, or pass apiKey to createHandler().\",\n );\n return json({ error: { code: \"internal\", message: \"Fewchurs is not configured.\" } }, 500);\n }\n\n const cookieValue = readCookie(request, DEVICE_ID_COOKIE);\n const custom = options.deviceId ? await options.deviceId() : null;\n const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? createDeviceId();\n\n const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${apiKey}`,\n \"X-Device-Id\": deviceId,\n \"X-Fewchurs-Sdk\": SDK_HEADER,\n \"X-Fewchurs-App-Id\":\n configuredAppId(options.appId) ??\n requestHostname(request.headers) ??\n new URL(request.url).hostname,\n Accept: \"application/json\",\n };\n const language = request.headers.get(\"accept-language\");\n if (language) headers[\"Accept-Language\"] = language;\n\n const body = request.method === \"GET\" ? undefined : await request.text();\n if (body) headers[\"Content-Type\"] = \"application/json\";\n\n let upstream: Response;\n try {\n upstream = await fetch(target, { method: request.method, headers, body, cache: \"no-store\" });\n } catch {\n return json({ error: { code: \"internal\", message: \"Could not reach Fewchurs.\" } }, 502);\n }\n\n const text = await upstream.text();\n if (upstream.status === 403) logRefusal(text);\n\n const response = new Response(text, {\n status: upstream.status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n\n // A visitor keeps the same id across requests without the browser ever seeing the key,\n // and an httpOnly cookie means page scripts cannot read or change it either.\n if (!custom && deviceId !== cookieValue) {\n response.headers.append(\"Set-Cookie\", serializeCookie(deviceId));\n }\n\n return response;\n }\n\n return { GET: handle, POST: handle, DELETE: handle };\n}\n\nconst defaultHandler = createHandler();\n\nexport const GET = defaultHandler.GET;\nexport const POST = defaultHandler.POST;\nexport const DELETE = defaultHandler.DELETE;\n\nlet loggedRefusal = false;\n\n/** Visitors only see a safe message, so a live site's server log is where the domain gets named. */\nfunction logRefusal(body: string): void {\n if (loggedRefusal) return;\n try {\n const error = (JSON.parse(body) as { error?: { code?: string; message?: string } }).error;\n if (error?.code !== \"wrong_app\") return;\n loggedRefusal = true;\n console.error(`[fewchurs] ${error.message}`);\n } catch {\n // Not JSON, so it isn't a refusal from Fewchurs.\n }\n}\n\nasync function routePath(\n request: Request,\n context: IncomingContext | undefined,\n apiPath: string,\n): Promise<string> {\n const params = await context?.params;\n if (params?.route?.length) return params.route.map(encodeURIComponent).join(\"/\");\n\n const { pathname } = new URL(request.url);\n const index = pathname.indexOf(apiPath);\n return index === -1 ? \"\" : pathname.slice(index + apiPath.length).replace(/^\\//, \"\");\n}\n\n/**\n * The first request from a server-rendered board carries the id the page was rendered with,\n * so adopting it keeps the server's `hasVoted` answers and the browser's in agreement.\n */\nfunction claimedDeviceId(request: Request): string | null {\n const claimed = request.headers.get(\"x-device-id\");\n return claimed && DEVICE_ID_PATTERN.test(claimed) ? claimed : null;\n}\n\nfunction readCookie(request: Request, name: string): string | null {\n const header = request.headers.get(\"cookie\");\n if (!header) return null;\n const match = header.match(new RegExp(`(?:^|;\\\\s*)${name}=([^;]*)`));\n return match?.[1] ? decodeURIComponent(match[1]) : null;\n}\n\nfunction serializeCookie(deviceId: string): string {\n const parts = [\n `${DEVICE_ID_COOKIE}=${encodeURIComponent(deviceId)}`,\n \"Path=/\",\n `Max-Age=${DEVICE_ID_MAX_AGE}`,\n \"HttpOnly\",\n \"SameSite=Lax\",\n ];\n if (process.env.NODE_ENV === \"production\") parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nfunction json(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n}\n","import \"server-only\";\nimport { headers } from \"next/headers\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\n/** A domain set by the developer, which replaces the one read from the request. */\nexport function configuredAppId(appId?: string): string | undefined {\n return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || undefined;\n}\n\n/** The site's host name without a port. A reverse proxy reports the public one in `x-forwarded-host`. */\nexport function requestHostname(requestHeaders: Headers): string | undefined {\n const forwarded = requestHeaders.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n const host = forwarded || requestHeaders.get(\"host\")?.trim();\n if (!host) return undefined;\n try {\n return new URL(`http://${host}`).hostname;\n } catch {\n return undefined;\n }\n}\n\nexport async function serverClient(\n deviceId: string | null,\n config: ServerConfig = {},\n): Promise<FewchursClient> {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n // Only read the request when needed, so a page with a set domain can still render statically.\n appId: configuredAppId(config.appId) ?? requestHostname(await headers()),\n });\n}\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.1.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":"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","names":["import_server_only","import_core","headers"]}
package/dist/handler.mjs CHANGED
@@ -3,8 +3,8 @@ import {
3
3
  GET,
4
4
  POST,
5
5
  createHandler
6
- } from "./chunk-VY4D2Z6L.mjs";
7
- import "./chunk-SSHXXI5K.mjs";
6
+ } from "./chunk-ZRYXVCLB.mjs";
7
+ import "./chunk-L57II7SI.mjs";
8
8
  export {
9
9
  DELETE,
10
10
  GET,
package/dist/identity.js CHANGED
@@ -33,7 +33,7 @@ var import_headers = require("next/headers");
33
33
  var import_core = require("@fewchurs/react/core");
34
34
 
35
35
  // src/version.ts
36
- var SDK_VERSION = "1.0.0";
36
+ var SDK_VERSION = "1.1.0";
37
37
 
38
38
  // src/constants.ts
39
39
  var DEVICE_ID_COOKIE = "fewchurs_did";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/identity.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.0.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAAO;AACP,qBAAwB;AACxB,kBAA+B;;;ACDxB,IAAM,cAAc;;;ACCpB,IAAM,mBAAmB;AACzB,IAAM,oBAAoB,KAAK,KAAK,KAAK;AAEzC,IAAM,aAAa,QAAQ,WAAW;;;AFK7C,eAAsB,eAAuC;AAC3D,QAAM,QAAQ,UAAM,wBAAQ;AAC5B,SAAO,MAAM,IAAI,gBAAgB,GAAG,SAAS;AAC/C;AAMA,eAAsB,gBAAgB,QAA4C;AAChF,MAAI,OAAQ,QAAO,OAAO;AAC1B,SAAQ,MAAM,aAAa,SAAM,4BAAe;AAClD;AAEO,SAAS,eAAe,UAU7B;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,IACP,SAAS;AAAA,MACP,UAAU;AAAA,MACV,UAAU;AAAA,MACV,MAAM;AAAA,MACN,QAAQ;AAAA,MACR,QAAQ,QAAQ,IAAI,aAAa;AAAA,IACnC;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/identity.ts","../src/version.ts","../src/constants.ts"],"sourcesContent":["import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.1.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAAO;AACP,qBAAwB;AACxB,kBAA+B;;;ACDxB,IAAM,cAAc;;;ACCpB,IAAM,mBAAmB;AACzB,IAAM,oBAAoB,KAAK,KAAK,KAAK;AAEzC,IAAM,aAAa,QAAQ,WAAW;;;AFK7C,eAAsB,eAAuC;AAC3D,QAAM,QAAQ,UAAM,wBAAQ;AAC5B,SAAO,MAAM,IAAI,gBAAgB,GAAG,SAAS;AAC/C;AAMA,eAAsB,gBAAgB,QAA4C;AAChF,MAAI,OAAQ,QAAO,OAAO;AAC1B,SAAQ,MAAM,aAAa,SAAM,4BAAe;AAClD;AAEO,SAAS,eAAe,UAU7B;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,IACP,SAAS;AAAA,MACP,UAAU;AAAA,MACV,UAAU;AAAA,MACV,MAAM;AAAA,MACN,QAAQ;AAAA,MACR,QAAQ,QAAQ,IAAI,aAAa;AAAA,IACnC;AAAA,EACF;AACF;","names":[]}
package/dist/identity.mjs CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  deviceIdCookie,
4
4
  readDeviceId,
5
5
  resolveDeviceId
6
- } from "./chunk-P6QQPQVC.mjs";
6
+ } from "./chunk-DM357WK6.mjs";
7
7
  import {
8
8
  DEVICE_ID_COOKIE,
9
9
  DEVICE_ID_MAX_AGE
10
- } from "./chunk-SSHXXI5K.mjs";
10
+ } from "./chunk-L57II7SI.mjs";
11
11
  export {
12
12
  DEVICE_ID_COOKIE,
13
13
  DEVICE_ID_MAX_AGE,
package/dist/index.d.mts CHANGED
@@ -13,15 +13,19 @@ interface FewchursBoardProps extends SerializableBoardProps {
13
13
  /** Where your route handler is mounted. Defaults to `/api/fewchurs`. */
14
14
  apiPath?: string;
15
15
  language?: string;
16
+ /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */
17
+ appId?: string;
16
18
  deviceId?: DeviceIdResolver;
17
19
  }
18
- declare function FewchursBoard({ apiKey, baseUrl, apiPath, language, deviceId: deviceIdOption, ...board }: FewchursBoardProps): Promise<React.JSX.Element>;
20
+ declare function FewchursBoard({ apiKey, baseUrl, apiPath, language, appId, deviceId: deviceIdOption, ...board }: FewchursBoardProps): Promise<React.JSX.Element>;
19
21
 
20
22
  declare const DEFAULT_API_PATH = "/api/fewchurs";
21
23
  interface ServerConfig {
22
24
  apiKey?: string;
23
25
  baseUrl?: string;
24
26
  language?: string;
27
+ /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */
28
+ appId?: string;
25
29
  }
26
30
 
27
31
  export { DEFAULT_API_PATH, DeviceIdResolver, FewchursBoard, type FewchursBoardProps, type ServerConfig };
package/dist/index.d.ts CHANGED
@@ -13,15 +13,19 @@ interface FewchursBoardProps extends SerializableBoardProps {
13
13
  /** Where your route handler is mounted. Defaults to `/api/fewchurs`. */
14
14
  apiPath?: string;
15
15
  language?: string;
16
+ /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */
17
+ appId?: string;
16
18
  deviceId?: DeviceIdResolver;
17
19
  }
18
- declare function FewchursBoard({ apiKey, baseUrl, apiPath, language, deviceId: deviceIdOption, ...board }: FewchursBoardProps): Promise<React.JSX.Element>;
20
+ declare function FewchursBoard({ apiKey, baseUrl, apiPath, language, appId, deviceId: deviceIdOption, ...board }: FewchursBoardProps): Promise<React.JSX.Element>;
19
21
 
20
22
  declare const DEFAULT_API_PATH = "/api/fewchurs";
21
23
  interface ServerConfig {
22
24
  apiKey?: string;
23
25
  baseUrl?: string;
24
26
  language?: string;
27
+ /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */
28
+ appId?: string;
25
29
  }
26
30
 
27
31
  export { DEFAULT_API_PATH, DeviceIdResolver, FewchursBoard, type FewchursBoardProps, type ServerConfig };
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ var import_core3 = require("@fewchurs/react/core");
36
36
 
37
37
  // src/config.ts
38
38
  var import_server_only = require("server-only");
39
+ var import_headers = require("next/headers");
39
40
  var import_core = require("@fewchurs/react/core");
40
41
  var DEFAULT_API_PATH = "/api/fewchurs";
41
42
  function resolveApiKey(apiKey) {
@@ -47,7 +48,20 @@ function resolveBaseUrl(baseUrl) {
47
48
  function resolveApiPath(apiPath) {
48
49
  return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\/$/, "");
49
50
  }
50
- function serverClient(deviceId, config = {}) {
51
+ function configuredAppId(appId) {
52
+ return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || void 0;
53
+ }
54
+ function requestHostname(requestHeaders) {
55
+ const forwarded = requestHeaders.get("x-forwarded-host")?.split(",")[0]?.trim();
56
+ const host = forwarded || requestHeaders.get("host")?.trim();
57
+ if (!host) return void 0;
58
+ try {
59
+ return new URL(`http://${host}`).hostname;
60
+ } catch {
61
+ return void 0;
62
+ }
63
+ }
64
+ async function serverClient(deviceId, config = {}) {
51
65
  const apiKey = resolveApiKey(config.apiKey);
52
66
  if (!apiKey) {
53
67
  throw new Error(
@@ -58,17 +72,19 @@ function serverClient(deviceId, config = {}) {
58
72
  apiKey,
59
73
  baseUrl: resolveBaseUrl(config.baseUrl),
60
74
  deviceId,
61
- language: config.language
75
+ language: config.language,
76
+ // Only read the request when needed, so a page with a set domain can still render statically.
77
+ appId: configuredAppId(config.appId) ?? requestHostname(await (0, import_headers.headers)())
62
78
  });
63
79
  }
64
80
 
65
81
  // src/identity.ts
66
82
  var import_server_only2 = require("server-only");
67
- var import_headers = require("next/headers");
83
+ var import_headers2 = require("next/headers");
68
84
  var import_core2 = require("@fewchurs/react/core");
69
85
 
70
86
  // src/version.ts
71
- var SDK_VERSION = "1.0.0";
87
+ var SDK_VERSION = "1.1.0";
72
88
 
73
89
  // src/constants.ts
74
90
  var DEVICE_ID_COOKIE = "fewchurs_did";
@@ -77,7 +93,7 @@ var SDK_HEADER = `next/${SDK_VERSION}`;
77
93
 
78
94
  // src/identity.ts
79
95
  async function readDeviceId() {
80
- const store = await (0, import_headers.cookies)();
96
+ const store = await (0, import_headers2.cookies)();
81
97
  return store.get(DEVICE_ID_COOKIE)?.value ?? null;
82
98
  }
83
99
  async function resolveDeviceId(custom) {
@@ -92,11 +108,12 @@ async function FewchursBoard({
92
108
  baseUrl,
93
109
  apiPath,
94
110
  language,
111
+ appId,
95
112
  deviceId: deviceIdOption,
96
113
  ...board
97
114
  }) {
98
115
  const deviceId = await resolveDeviceId(deviceIdOption);
99
- const client = serverClient(deviceId, { apiKey, baseUrl, language });
116
+ const client = await serverClient(deviceId, { apiKey, baseUrl, language, appId });
100
117
  const tab = board.defaultTab ?? board.tabs?.[0] ?? "top";
101
118
  const sort = tab === "roadmap" ? "top" : tab;
102
119
  const status = tab === "roadmap" ? import_core3.ROADMAP_STATUSES : void 0;
@@ -172,23 +189,26 @@ function createHandler(options = {}) {
172
189
  const custom = options.deviceId ? await options.deviceId() : null;
173
190
  const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? (0, import_core4.createDeviceId)();
174
191
  const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;
175
- const headers = {
192
+ const headers2 = {
176
193
  Authorization: `Bearer ${apiKey}`,
177
194
  "X-Device-Id": deviceId,
178
- "X-Fewchurs-Sdk": request.headers.get("x-fewchurs-sdk") ?? SDK_HEADER,
195
+ "X-Fewchurs-Sdk": SDK_HEADER,
196
+ "X-Fewchurs-App-Id": configuredAppId(options.appId) ?? requestHostname(request.headers) ?? new URL(request.url).hostname,
179
197
  Accept: "application/json"
180
198
  };
181
199
  const language = request.headers.get("accept-language");
182
- if (language) headers["Accept-Language"] = language;
200
+ if (language) headers2["Accept-Language"] = language;
183
201
  const body = request.method === "GET" ? void 0 : await request.text();
184
- if (body) headers["Content-Type"] = "application/json";
202
+ if (body) headers2["Content-Type"] = "application/json";
185
203
  let upstream;
186
204
  try {
187
- upstream = await fetch(target, { method: request.method, headers, body, cache: "no-store" });
205
+ upstream = await fetch(target, { method: request.method, headers: headers2, body, cache: "no-store" });
188
206
  } catch {
189
207
  return json({ error: { code: "internal", message: "Could not reach Fewchurs." } }, 502);
190
208
  }
191
- const response = new Response(await upstream.text(), {
209
+ const text = await upstream.text();
210
+ if (upstream.status === 403) logRefusal(text);
211
+ const response = new Response(text, {
192
212
  status: upstream.status,
193
213
  headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
194
214
  });
@@ -203,6 +223,17 @@ var defaultHandler = createHandler();
203
223
  var GET = defaultHandler.GET;
204
224
  var POST = defaultHandler.POST;
205
225
  var DELETE = defaultHandler.DELETE;
226
+ var loggedRefusal = false;
227
+ function logRefusal(body) {
228
+ if (loggedRefusal) return;
229
+ try {
230
+ const error = JSON.parse(body).error;
231
+ if (error?.code !== "wrong_app") return;
232
+ loggedRefusal = true;
233
+ console.error(`[fewchurs] ${error.message}`);
234
+ } catch {
235
+ }
236
+ }
206
237
  async function routePath(request, context, apiPath) {
207
238
  const params = await context?.params;
208
239
  if (params?.route?.length) return params.route.map(encodeURIComponent).join("/");
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/board.tsx","../src/config.ts","../src/identity.ts","../src/version.ts","../src/constants.ts","../src/handler.ts"],"sourcesContent":["export { FewchursBoard } from \"./board\";\nexport type { FewchursBoardProps } from \"./board\";\nexport { createHandler } from \"./handler\";\nexport type { HandlerOptions, RouteHandler } from \"./handler\";\nexport { DEVICE_ID_COOKIE, readDeviceId, resolveDeviceId } from \"./identity\";\nexport type { DeviceIdResolver } from \"./identity\";\nexport { DEFAULT_API_PATH } from \"./config\";\nexport type { ServerConfig } from \"./config\";\n\nexport type {\n Comment,\n CommentPage,\n Feature,\n FeaturePage,\n FeatureSort,\n FeatureStatus,\n FewchursConfig,\n FewchursError,\n} from \"@fewchurs/react/core\";\n","import \"server-only\";\nimport { FewchursBoard as ClientBoard, FewchursProvider } from \"@fewchurs/react\";\nimport type { FewchursBoardProps as ClientBoardProps } from \"@fewchurs/react\";\nimport {\n featureKey,\n listKey,\n ROADMAP_STATUSES,\n type FeatureSort,\n type StoreState,\n} from \"@fewchurs/react/core\";\nimport { resolveApiPath, serverClient } from \"./config\";\nimport { resolveDeviceId, type DeviceIdResolver } from \"./identity\";\n\n/** Function props cannot cross the server/client boundary — compose in your own client file. */\ntype SerializableBoardProps = Omit<\n ClientBoardProps,\n \"renderHeader\" | \"renderEmpty\" | \"renderFeature\" | \"children\"\n>;\n\nexport interface FewchursBoardProps extends SerializableBoardProps {\n /** Defaults to `process.env.FEWCHURS_API_KEY`. Never reaches the browser. */\n apiKey?: string;\n baseUrl?: string;\n /** Where your route handler is mounted. Defaults to `/api/fewchurs`. */\n apiPath?: string;\n language?: string;\n deviceId?: DeviceIdResolver;\n}\n\nexport async function FewchursBoard({\n apiKey,\n baseUrl,\n apiPath,\n language,\n deviceId: deviceIdOption,\n ...board\n}: FewchursBoardProps): Promise<React.JSX.Element> {\n const deviceId = await resolveDeviceId(deviceIdOption);\n const client = serverClient(deviceId, { apiKey, baseUrl, language });\n\n const tab = board.defaultTab ?? board.tabs?.[0] ?? \"top\";\n const sort: FeatureSort = tab === \"roadmap\" ? \"top\" : tab;\n const status = tab === \"roadmap\" ? ROADMAP_STATUSES : undefined;\n const pageSize = board.pageSize ?? 20;\n\n const [config, page, pinned] = await Promise.all([\n client.getConfig(),\n client.listFeatures({ sort, status, limit: pageSize }),\n board.initialFeatureId ? client.getFeature(board.initialFeatureId) : null,\n ]);\n\n // Seeded under the same key the client hook will ask for, so the first paint is the\n // rendered HTML and no second request goes out just to fetch what is already there.\n const lists: StoreState[\"lists\"] = {};\n if (page.ok) {\n lists[listKey({ sort, status })] = {\n features: page.data.features,\n cursor: page.data.nextCursor,\n status: \"ready\",\n error: null,\n };\n }\n // A deep-linked request is rendered on the server too, so the link lands on real content.\n if (board.initialFeatureId && pinned?.ok) {\n lists[featureKey(board.initialFeatureId)] = {\n features: [pinned.data],\n cursor: null,\n status: \"ready\",\n error: null,\n };\n }\n\n const initialState: Partial<StoreState> = {\n config: config.ok ? config.data : null,\n lists,\n };\n\n return (\n <FewchursProvider\n baseUrl={resolveApiPath(apiPath)}\n deviceId={deviceId}\n pageSize={pageSize}\n initialState={initialState}\n >\n <ClientBoard {...board} />\n </FewchursProvider>\n );\n}\n","import \"server-only\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\nexport function serverClient(deviceId: string | null, config: ServerConfig = {}): FewchursClient {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n });\n}\n","import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.0.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n","import \"server-only\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { resolveApiKey, resolveApiPath, resolveBaseUrl } from \"./config\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE, SDK_HEADER } from \"./constants\";\nimport type { DeviceIdResolver } from \"./identity\";\n\nexport interface HandlerOptions {\n apiKey?: string;\n baseUrl?: string;\n /** Where the handler is mounted, when it is not `/api/fewchurs`. */\n apiPath?: string;\n /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */\n deviceId?: DeviceIdResolver;\n}\n\ntype RouteParams = { route?: string[] };\n\n/** What Next passes: a Promise in 15, a plain object in 14. Both are awaited the same way. */\ntype IncomingContext = { params?: RouteParams | Promise<RouteParams> };\n\n// Next 15's route check wants a required second argument whose `params` is a Promise. The\n// handler itself takes `IncomingContext`, so Next 14's plain object still works at runtime.\ntype RouteContext = { params: Promise<RouteParams> };\n\nexport type RouteHandler = (request: Request, context: RouteContext) => Promise<Response>;\n\n/** Only the endpoints the board needs: everything else is a 404, not a proxy to the API. */\nconst ALLOWED: Array<{ method: string; pattern: RegExp }> = [\n { method: \"GET\", pattern: /^config$/ },\n { method: \"GET\", pattern: /^features$/ },\n { method: \"POST\", pattern: /^features$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+\\/comments$/ },\n { method: \"POST\", pattern: /^comments$/ },\n];\n\nconst DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport function createHandler(options: HandlerOptions = {}): {\n GET: RouteHandler;\n POST: RouteHandler;\n DELETE: RouteHandler;\n} {\n async function handle(request: Request, context?: IncomingContext): Promise<Response> {\n const path = await routePath(request, context, resolveApiPath(options.apiPath));\n\n if (!ALLOWED.some((route) => route.method === request.method && route.pattern.test(path))) {\n return json({ error: { code: \"not_found\", message: \"Unknown Fewchurs route.\" } }, 404);\n }\n\n const apiKey = resolveApiKey(options.apiKey);\n if (!apiKey) {\n console.error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY, or pass apiKey to createHandler().\",\n );\n return json({ error: { code: \"internal\", message: \"Fewchurs is not configured.\" } }, 500);\n }\n\n const cookieValue = readCookie(request, DEVICE_ID_COOKIE);\n const custom = options.deviceId ? await options.deviceId() : null;\n const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? createDeviceId();\n\n const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${apiKey}`,\n \"X-Device-Id\": deviceId,\n \"X-Fewchurs-Sdk\": request.headers.get(\"x-fewchurs-sdk\") ?? SDK_HEADER,\n Accept: \"application/json\",\n };\n const language = request.headers.get(\"accept-language\");\n if (language) headers[\"Accept-Language\"] = language;\n\n const body = request.method === \"GET\" ? undefined : await request.text();\n if (body) headers[\"Content-Type\"] = \"application/json\";\n\n let upstream: Response;\n try {\n upstream = await fetch(target, { method: request.method, headers, body, cache: \"no-store\" });\n } catch {\n return json({ error: { code: \"internal\", message: \"Could not reach Fewchurs.\" } }, 502);\n }\n\n const response = new Response(await upstream.text(), {\n status: upstream.status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n\n // A visitor keeps the same id across requests without the browser ever seeing the key,\n // and an httpOnly cookie means page scripts cannot read or change it either.\n if (!custom && deviceId !== cookieValue) {\n response.headers.append(\"Set-Cookie\", serializeCookie(deviceId));\n }\n\n return response;\n }\n\n return { GET: handle, POST: handle, DELETE: handle };\n}\n\nconst defaultHandler = createHandler();\n\nexport const GET = defaultHandler.GET;\nexport const POST = defaultHandler.POST;\nexport const DELETE = defaultHandler.DELETE;\n\nasync function routePath(\n request: Request,\n context: IncomingContext | undefined,\n apiPath: string,\n): Promise<string> {\n const params = await context?.params;\n if (params?.route?.length) return params.route.map(encodeURIComponent).join(\"/\");\n\n const { pathname } = new URL(request.url);\n const index = pathname.indexOf(apiPath);\n return index === -1 ? \"\" : pathname.slice(index + apiPath.length).replace(/^\\//, \"\");\n}\n\n/**\n * The first request from a server-rendered board carries the id the page was rendered with,\n * so adopting it keeps the server's `hasVoted` answers and the browser's in agreement.\n */\nfunction claimedDeviceId(request: Request): string | null {\n const claimed = request.headers.get(\"x-device-id\");\n return claimed && DEVICE_ID_PATTERN.test(claimed) ? claimed : null;\n}\n\nfunction readCookie(request: Request, name: string): string | null {\n const header = request.headers.get(\"cookie\");\n if (!header) return null;\n const match = header.match(new RegExp(`(?:^|;\\\\s*)${name}=([^;]*)`));\n return match?.[1] ? decodeURIComponent(match[1]) : null;\n}\n\nfunction serializeCookie(deviceId: string): string {\n const parts = [\n `${DEVICE_ID_COOKIE}=${encodeURIComponent(deviceId)}`,\n \"Path=/\",\n `Max-Age=${DEVICE_ID_MAX_AGE}`,\n \"HttpOnly\",\n \"SameSite=Lax\",\n ];\n if (process.env.NODE_ENV === \"production\") parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nfunction json(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n}\n"],"mappings":"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","names":["import_server_only","import_core","import_server_only","import_core","ClientBoard","import_server_only","import_core"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/board.tsx","../src/config.ts","../src/identity.ts","../src/version.ts","../src/constants.ts","../src/handler.ts"],"sourcesContent":["export { FewchursBoard } from \"./board\";\nexport type { FewchursBoardProps } from \"./board\";\nexport { createHandler } from \"./handler\";\nexport type { HandlerOptions, RouteHandler } from \"./handler\";\nexport { DEVICE_ID_COOKIE, readDeviceId, resolveDeviceId } from \"./identity\";\nexport type { DeviceIdResolver } from \"./identity\";\nexport { DEFAULT_API_PATH } from \"./config\";\nexport type { ServerConfig } from \"./config\";\n\nexport type {\n Comment,\n CommentPage,\n Feature,\n FeaturePage,\n FeatureSort,\n FeatureStatus,\n FewchursConfig,\n FewchursError,\n} from \"@fewchurs/react/core\";\n","import \"server-only\";\nimport { FewchursBoard as ClientBoard, FewchursProvider } from \"@fewchurs/react\";\nimport type { FewchursBoardProps as ClientBoardProps } from \"@fewchurs/react\";\nimport {\n featureKey,\n listKey,\n ROADMAP_STATUSES,\n type FeatureSort,\n type StoreState,\n} from \"@fewchurs/react/core\";\nimport { resolveApiPath, serverClient } from \"./config\";\nimport { resolveDeviceId, type DeviceIdResolver } from \"./identity\";\n\n/** Function props cannot cross the server/client boundary — compose in your own client file. */\ntype SerializableBoardProps = Omit<\n ClientBoardProps,\n \"renderHeader\" | \"renderEmpty\" | \"renderFeature\" | \"children\"\n>;\n\nexport interface FewchursBoardProps extends SerializableBoardProps {\n /** Defaults to `process.env.FEWCHURS_API_KEY`. Never reaches the browser. */\n apiKey?: string;\n baseUrl?: string;\n /** Where your route handler is mounted. Defaults to `/api/fewchurs`. */\n apiPath?: string;\n language?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n deviceId?: DeviceIdResolver;\n}\n\nexport async function FewchursBoard({\n apiKey,\n baseUrl,\n apiPath,\n language,\n appId,\n deviceId: deviceIdOption,\n ...board\n}: FewchursBoardProps): Promise<React.JSX.Element> {\n const deviceId = await resolveDeviceId(deviceIdOption);\n const client = await serverClient(deviceId, { apiKey, baseUrl, language, appId });\n\n const tab = board.defaultTab ?? board.tabs?.[0] ?? \"top\";\n const sort: FeatureSort = tab === \"roadmap\" ? \"top\" : tab;\n const status = tab === \"roadmap\" ? ROADMAP_STATUSES : undefined;\n const pageSize = board.pageSize ?? 20;\n\n const [config, page, pinned] = await Promise.all([\n client.getConfig(),\n client.listFeatures({ sort, status, limit: pageSize }),\n board.initialFeatureId ? client.getFeature(board.initialFeatureId) : null,\n ]);\n\n // Seeded under the same key the client hook will ask for, so the first paint is the\n // rendered HTML and no second request goes out just to fetch what is already there.\n const lists: StoreState[\"lists\"] = {};\n if (page.ok) {\n lists[listKey({ sort, status })] = {\n features: page.data.features,\n cursor: page.data.nextCursor,\n status: \"ready\",\n error: null,\n };\n }\n // A deep-linked request is rendered on the server too, so the link lands on real content.\n if (board.initialFeatureId && pinned?.ok) {\n lists[featureKey(board.initialFeatureId)] = {\n features: [pinned.data],\n cursor: null,\n status: \"ready\",\n error: null,\n };\n }\n\n const initialState: Partial<StoreState> = {\n config: config.ok ? config.data : null,\n lists,\n };\n\n return (\n <FewchursProvider\n baseUrl={resolveApiPath(apiPath)}\n deviceId={deviceId}\n pageSize={pageSize}\n initialState={initialState}\n >\n <ClientBoard {...board} />\n </FewchursProvider>\n );\n}\n","import \"server-only\";\nimport { headers } from \"next/headers\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\n/** A domain set by the developer, which replaces the one read from the request. */\nexport function configuredAppId(appId?: string): string | undefined {\n return appId?.trim() || process.env.FEWCHURS_APP_ID?.trim() || undefined;\n}\n\n/** The site's host name without a port. A reverse proxy reports the public one in `x-forwarded-host`. */\nexport function requestHostname(requestHeaders: Headers): string | undefined {\n const forwarded = requestHeaders.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n const host = forwarded || requestHeaders.get(\"host\")?.trim();\n if (!host) return undefined;\n try {\n return new URL(`http://${host}`).hostname;\n } catch {\n return undefined;\n }\n}\n\nexport async function serverClient(\n deviceId: string | null,\n config: ServerConfig = {},\n): Promise<FewchursClient> {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n // Only read the request when needed, so a page with a set domain can still render statically.\n appId: configuredAppId(config.appId) ?? requestHostname(await headers()),\n });\n}\n","import \"server-only\";\nimport { cookies } from \"next/headers\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE } from \"./constants\";\n\nexport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE };\n\nexport type DeviceIdResolver = () => string | Promise<string>;\n\n/** The visitor's id, or null when nothing has been stored for them yet. */\nexport async function readDeviceId(): Promise<string | null> {\n const store = await cookies();\n return store.get(DEVICE_ID_COOKIE)?.value ?? null;\n}\n\n/**\n * Reads the cookie, falling back to a fresh id. The value is not persisted here: a server\n * component cannot set cookies, so the route handler stores it on the first request instead.\n */\nexport async function resolveDeviceId(custom?: DeviceIdResolver): Promise<string> {\n if (custom) return custom();\n return (await readDeviceId()) ?? createDeviceId();\n}\n\nexport function deviceIdCookie(deviceId: string): {\n name: string;\n value: string;\n options: {\n httpOnly: true;\n sameSite: \"lax\";\n path: string;\n maxAge: number;\n secure: boolean;\n };\n} {\n return {\n name: DEVICE_ID_COOKIE,\n value: deviceId,\n options: {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: DEVICE_ID_MAX_AGE,\n secure: process.env.NODE_ENV === \"production\",\n },\n };\n}\n\nexport { createDeviceId };\n","// Generated by tools/stamp-version.mjs from @fewchurs/next's package.json. Do not edit by hand.\nexport const SDK_VERSION = \"1.1.0\";\n","import { SDK_VERSION } from \"./version\";\n\nexport const DEVICE_ID_COOKIE = \"fewchurs_did\";\nexport const DEVICE_ID_MAX_AGE = 60 * 60 * 24 * 365;\nexport { SDK_VERSION };\nexport const SDK_HEADER = `next/${SDK_VERSION}`;\n","import \"server-only\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport {\n configuredAppId,\n requestHostname,\n resolveApiKey,\n resolveApiPath,\n resolveBaseUrl,\n} from \"./config\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE, SDK_HEADER } from \"./constants\";\nimport type { DeviceIdResolver } from \"./identity\";\n\nexport interface HandlerOptions {\n apiKey?: string;\n baseUrl?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n /** Where the handler is mounted, when it is not `/api/fewchurs`. */\n apiPath?: string;\n /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */\n deviceId?: DeviceIdResolver;\n}\n\ntype RouteParams = { route?: string[] };\n\n/** What Next passes: a Promise in 15, a plain object in 14. Both are awaited the same way. */\ntype IncomingContext = { params?: RouteParams | Promise<RouteParams> };\n\n// Next 15's route check wants a required second argument whose `params` is a Promise. The\n// handler itself takes `IncomingContext`, so Next 14's plain object still works at runtime.\ntype RouteContext = { params: Promise<RouteParams> };\n\nexport type RouteHandler = (request: Request, context: RouteContext) => Promise<Response>;\n\n/** Only the endpoints the board needs: everything else is a 404, not a proxy to the API. */\nconst ALLOWED: Array<{ method: string; pattern: RegExp }> = [\n { method: \"GET\", pattern: /^config$/ },\n { method: \"GET\", pattern: /^features$/ },\n { method: \"POST\", pattern: /^features$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+\\/comments$/ },\n { method: \"POST\", pattern: /^comments$/ },\n];\n\nconst DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport function createHandler(options: HandlerOptions = {}): {\n GET: RouteHandler;\n POST: RouteHandler;\n DELETE: RouteHandler;\n} {\n async function handle(request: Request, context?: IncomingContext): Promise<Response> {\n const path = await routePath(request, context, resolveApiPath(options.apiPath));\n\n if (!ALLOWED.some((route) => route.method === request.method && route.pattern.test(path))) {\n return json({ error: { code: \"not_found\", message: \"Unknown Fewchurs route.\" } }, 404);\n }\n\n const apiKey = resolveApiKey(options.apiKey);\n if (!apiKey) {\n console.error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY, or pass apiKey to createHandler().\",\n );\n return json({ error: { code: \"internal\", message: \"Fewchurs is not configured.\" } }, 500);\n }\n\n const cookieValue = readCookie(request, DEVICE_ID_COOKIE);\n const custom = options.deviceId ? await options.deviceId() : null;\n const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? createDeviceId();\n\n const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${apiKey}`,\n \"X-Device-Id\": deviceId,\n \"X-Fewchurs-Sdk\": SDK_HEADER,\n \"X-Fewchurs-App-Id\":\n configuredAppId(options.appId) ??\n requestHostname(request.headers) ??\n new URL(request.url).hostname,\n Accept: \"application/json\",\n };\n const language = request.headers.get(\"accept-language\");\n if (language) headers[\"Accept-Language\"] = language;\n\n const body = request.method === \"GET\" ? undefined : await request.text();\n if (body) headers[\"Content-Type\"] = \"application/json\";\n\n let upstream: Response;\n try {\n upstream = await fetch(target, { method: request.method, headers, body, cache: \"no-store\" });\n } catch {\n return json({ error: { code: \"internal\", message: \"Could not reach Fewchurs.\" } }, 502);\n }\n\n const text = await upstream.text();\n if (upstream.status === 403) logRefusal(text);\n\n const response = new Response(text, {\n status: upstream.status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n\n // A visitor keeps the same id across requests without the browser ever seeing the key,\n // and an httpOnly cookie means page scripts cannot read or change it either.\n if (!custom && deviceId !== cookieValue) {\n response.headers.append(\"Set-Cookie\", serializeCookie(deviceId));\n }\n\n return response;\n }\n\n return { GET: handle, POST: handle, DELETE: handle };\n}\n\nconst defaultHandler = createHandler();\n\nexport const GET = defaultHandler.GET;\nexport const POST = defaultHandler.POST;\nexport const DELETE = defaultHandler.DELETE;\n\nlet loggedRefusal = false;\n\n/** Visitors only see a safe message, so a live site's server log is where the domain gets named. */\nfunction logRefusal(body: string): void {\n if (loggedRefusal) return;\n try {\n const error = (JSON.parse(body) as { error?: { code?: string; message?: string } }).error;\n if (error?.code !== \"wrong_app\") return;\n loggedRefusal = true;\n console.error(`[fewchurs] ${error.message}`);\n } catch {\n // Not JSON, so it isn't a refusal from Fewchurs.\n }\n}\n\nasync function routePath(\n request: Request,\n context: IncomingContext | undefined,\n apiPath: string,\n): Promise<string> {\n const params = await context?.params;\n if (params?.route?.length) return params.route.map(encodeURIComponent).join(\"/\");\n\n const { pathname } = new URL(request.url);\n const index = pathname.indexOf(apiPath);\n return index === -1 ? \"\" : pathname.slice(index + apiPath.length).replace(/^\\//, \"\");\n}\n\n/**\n * The first request from a server-rendered board carries the id the page was rendered with,\n * so adopting it keeps the server's `hasVoted` answers and the browser's in agreement.\n */\nfunction claimedDeviceId(request: Request): string | null {\n const claimed = request.headers.get(\"x-device-id\");\n return claimed && DEVICE_ID_PATTERN.test(claimed) ? claimed : null;\n}\n\nfunction readCookie(request: Request, name: string): string | null {\n const header = request.headers.get(\"cookie\");\n if (!header) return null;\n const match = header.match(new RegExp(`(?:^|;\\\\s*)${name}=([^;]*)`));\n return match?.[1] ? decodeURIComponent(match[1]) : null;\n}\n\nfunction serializeCookie(deviceId: string): string {\n const parts = [\n `${DEVICE_ID_COOKIE}=${encodeURIComponent(deviceId)}`,\n \"Path=/\",\n `Max-Age=${DEVICE_ID_MAX_AGE}`,\n \"HttpOnly\",\n \"SameSite=Lax\",\n ];\n if (process.env.NODE_ENV === \"production\") parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nfunction json(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n}\n"],"mappings":"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","names":["import_server_only","import_core","import_server_only","import_headers","import_core","ClientBoard","import_server_only","import_core","headers"]}
package/dist/index.mjs CHANGED
@@ -3,14 +3,14 @@ import {
3
3
  createHandler,
4
4
  resolveApiPath,
5
5
  serverClient
6
- } from "./chunk-VY4D2Z6L.mjs";
6
+ } from "./chunk-ZRYXVCLB.mjs";
7
7
  import {
8
8
  readDeviceId,
9
9
  resolveDeviceId
10
- } from "./chunk-P6QQPQVC.mjs";
10
+ } from "./chunk-DM357WK6.mjs";
11
11
  import {
12
12
  DEVICE_ID_COOKIE
13
- } from "./chunk-SSHXXI5K.mjs";
13
+ } from "./chunk-L57II7SI.mjs";
14
14
 
15
15
  // src/board.tsx
16
16
  import "server-only";
@@ -26,11 +26,12 @@ async function FewchursBoard({
26
26
  baseUrl,
27
27
  apiPath,
28
28
  language,
29
+ appId,
29
30
  deviceId: deviceIdOption,
30
31
  ...board
31
32
  }) {
32
33
  const deviceId = await resolveDeviceId(deviceIdOption);
33
- const client = serverClient(deviceId, { apiKey, baseUrl, language });
34
+ const client = await serverClient(deviceId, { apiKey, baseUrl, language, appId });
34
35
  const tab = board.defaultTab ?? board.tabs?.[0] ?? "top";
35
36
  const sort = tab === "roadmap" ? "top" : tab;
36
37
  const status = tab === "roadmap" ? ROADMAP_STATUSES : void 0;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/board.tsx"],"sourcesContent":["import \"server-only\";\nimport { FewchursBoard as ClientBoard, FewchursProvider } from \"@fewchurs/react\";\nimport type { FewchursBoardProps as ClientBoardProps } from \"@fewchurs/react\";\nimport {\n featureKey,\n listKey,\n ROADMAP_STATUSES,\n type FeatureSort,\n type StoreState,\n} from \"@fewchurs/react/core\";\nimport { resolveApiPath, serverClient } from \"./config\";\nimport { resolveDeviceId, type DeviceIdResolver } from \"./identity\";\n\n/** Function props cannot cross the server/client boundary — compose in your own client file. */\ntype SerializableBoardProps = Omit<\n ClientBoardProps,\n \"renderHeader\" | \"renderEmpty\" | \"renderFeature\" | \"children\"\n>;\n\nexport interface FewchursBoardProps extends SerializableBoardProps {\n /** Defaults to `process.env.FEWCHURS_API_KEY`. Never reaches the browser. */\n apiKey?: string;\n baseUrl?: string;\n /** Where your route handler is mounted. Defaults to `/api/fewchurs`. */\n apiPath?: string;\n language?: string;\n deviceId?: DeviceIdResolver;\n}\n\nexport async function FewchursBoard({\n apiKey,\n baseUrl,\n apiPath,\n language,\n deviceId: deviceIdOption,\n ...board\n}: FewchursBoardProps): Promise<React.JSX.Element> {\n const deviceId = await resolveDeviceId(deviceIdOption);\n const client = serverClient(deviceId, { apiKey, baseUrl, language });\n\n const tab = board.defaultTab ?? board.tabs?.[0] ?? \"top\";\n const sort: FeatureSort = tab === \"roadmap\" ? \"top\" : tab;\n const status = tab === \"roadmap\" ? ROADMAP_STATUSES : undefined;\n const pageSize = board.pageSize ?? 20;\n\n const [config, page, pinned] = await Promise.all([\n client.getConfig(),\n client.listFeatures({ sort, status, limit: pageSize }),\n board.initialFeatureId ? client.getFeature(board.initialFeatureId) : null,\n ]);\n\n // Seeded under the same key the client hook will ask for, so the first paint is the\n // rendered HTML and no second request goes out just to fetch what is already there.\n const lists: StoreState[\"lists\"] = {};\n if (page.ok) {\n lists[listKey({ sort, status })] = {\n features: page.data.features,\n cursor: page.data.nextCursor,\n status: \"ready\",\n error: null,\n };\n }\n // A deep-linked request is rendered on the server too, so the link lands on real content.\n if (board.initialFeatureId && pinned?.ok) {\n lists[featureKey(board.initialFeatureId)] = {\n features: [pinned.data],\n cursor: null,\n status: \"ready\",\n error: null,\n };\n }\n\n const initialState: Partial<StoreState> = {\n config: config.ok ? config.data : null,\n lists,\n };\n\n return (\n <FewchursProvider\n baseUrl={resolveApiPath(apiPath)}\n deviceId={deviceId}\n pageSize={pageSize}\n initialState={initialState}\n >\n <ClientBoard {...board} />\n </FewchursProvider>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAAA,OAAO;AACP,SAAS,iBAAiB,aAAa,wBAAwB;AAE/D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AA2ED;AAvDN,eAAsB,cAAc;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,GAAG;AACL,GAAmD;AACjD,QAAM,WAAW,MAAM,gBAAgB,cAAc;AACrD,QAAM,SAAS,aAAa,UAAU,EAAE,QAAQ,SAAS,SAAS,CAAC;AAEnE,QAAM,MAAM,MAAM,cAAc,MAAM,OAAO,CAAC,KAAK;AACnD,QAAM,OAAoB,QAAQ,YAAY,QAAQ;AACtD,QAAM,SAAS,QAAQ,YAAY,mBAAmB;AACtD,QAAM,WAAW,MAAM,YAAY;AAEnC,QAAM,CAAC,QAAQ,MAAM,MAAM,IAAI,MAAM,QAAQ,IAAI;AAAA,IAC/C,OAAO,UAAU;AAAA,IACjB,OAAO,aAAa,EAAE,MAAM,QAAQ,OAAO,SAAS,CAAC;AAAA,IACrD,MAAM,mBAAmB,OAAO,WAAW,MAAM,gBAAgB,IAAI;AAAA,EACvE,CAAC;AAID,QAAM,QAA6B,CAAC;AACpC,MAAI,KAAK,IAAI;AACX,UAAM,QAAQ,EAAE,MAAM,OAAO,CAAC,CAAC,IAAI;AAAA,MACjC,UAAU,KAAK,KAAK;AAAA,MACpB,QAAQ,KAAK,KAAK;AAAA,MAClB,QAAQ;AAAA,MACR,OAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,MAAM,oBAAoB,QAAQ,IAAI;AACxC,UAAM,WAAW,MAAM,gBAAgB,CAAC,IAAI;AAAA,MAC1C,UAAU,CAAC,OAAO,IAAI;AAAA,MACtB,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,OAAO;AAAA,IACT;AAAA,EACF;AAEA,QAAM,eAAoC;AAAA,IACxC,QAAQ,OAAO,KAAK,OAAO,OAAO;AAAA,IAClC;AAAA,EACF;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,SAAS,eAAe,OAAO;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA,MAEA,8BAAC,eAAa,GAAG,OAAO;AAAA;AAAA,EAC1B;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../src/board.tsx"],"sourcesContent":["import \"server-only\";\nimport { FewchursBoard as ClientBoard, FewchursProvider } from \"@fewchurs/react\";\nimport type { FewchursBoardProps as ClientBoardProps } from \"@fewchurs/react\";\nimport {\n featureKey,\n listKey,\n ROADMAP_STATUSES,\n type FeatureSort,\n type StoreState,\n} from \"@fewchurs/react/core\";\nimport { resolveApiPath, serverClient } from \"./config\";\nimport { resolveDeviceId, type DeviceIdResolver } from \"./identity\";\n\n/** Function props cannot cross the server/client boundary — compose in your own client file. */\ntype SerializableBoardProps = Omit<\n ClientBoardProps,\n \"renderHeader\" | \"renderEmpty\" | \"renderFeature\" | \"children\"\n>;\n\nexport interface FewchursBoardProps extends SerializableBoardProps {\n /** Defaults to `process.env.FEWCHURS_API_KEY`. Never reaches the browser. */\n apiKey?: string;\n baseUrl?: string;\n /** Where your route handler is mounted. Defaults to `/api/fewchurs`. */\n apiPath?: string;\n language?: string;\n /** The domain the API key is checked against. Defaults to `FEWCHURS_APP_ID`, then your site's host. */\n appId?: string;\n deviceId?: DeviceIdResolver;\n}\n\nexport async function FewchursBoard({\n apiKey,\n baseUrl,\n apiPath,\n language,\n appId,\n deviceId: deviceIdOption,\n ...board\n}: FewchursBoardProps): Promise<React.JSX.Element> {\n const deviceId = await resolveDeviceId(deviceIdOption);\n const client = await serverClient(deviceId, { apiKey, baseUrl, language, appId });\n\n const tab = board.defaultTab ?? board.tabs?.[0] ?? \"top\";\n const sort: FeatureSort = tab === \"roadmap\" ? \"top\" : tab;\n const status = tab === \"roadmap\" ? ROADMAP_STATUSES : undefined;\n const pageSize = board.pageSize ?? 20;\n\n const [config, page, pinned] = await Promise.all([\n client.getConfig(),\n client.listFeatures({ sort, status, limit: pageSize }),\n board.initialFeatureId ? client.getFeature(board.initialFeatureId) : null,\n ]);\n\n // Seeded under the same key the client hook will ask for, so the first paint is the\n // rendered HTML and no second request goes out just to fetch what is already there.\n const lists: StoreState[\"lists\"] = {};\n if (page.ok) {\n lists[listKey({ sort, status })] = {\n features: page.data.features,\n cursor: page.data.nextCursor,\n status: \"ready\",\n error: null,\n };\n }\n // A deep-linked request is rendered on the server too, so the link lands on real content.\n if (board.initialFeatureId && pinned?.ok) {\n lists[featureKey(board.initialFeatureId)] = {\n features: [pinned.data],\n cursor: null,\n status: \"ready\",\n error: null,\n };\n }\n\n const initialState: Partial<StoreState> = {\n config: config.ok ? config.data : null,\n lists,\n };\n\n return (\n <FewchursProvider\n baseUrl={resolveApiPath(apiPath)}\n deviceId={deviceId}\n pageSize={pageSize}\n initialState={initialState}\n >\n <ClientBoard {...board} />\n </FewchursProvider>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAAA,OAAO;AACP,SAAS,iBAAiB,aAAa,wBAAwB;AAE/D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AA8ED;AAxDN,eAAsB,cAAc;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,GAAG;AACL,GAAmD;AACjD,QAAM,WAAW,MAAM,gBAAgB,cAAc;AACrD,QAAM,SAAS,MAAM,aAAa,UAAU,EAAE,QAAQ,SAAS,UAAU,MAAM,CAAC;AAEhF,QAAM,MAAM,MAAM,cAAc,MAAM,OAAO,CAAC,KAAK;AACnD,QAAM,OAAoB,QAAQ,YAAY,QAAQ;AACtD,QAAM,SAAS,QAAQ,YAAY,mBAAmB;AACtD,QAAM,WAAW,MAAM,YAAY;AAEnC,QAAM,CAAC,QAAQ,MAAM,MAAM,IAAI,MAAM,QAAQ,IAAI;AAAA,IAC/C,OAAO,UAAU;AAAA,IACjB,OAAO,aAAa,EAAE,MAAM,QAAQ,OAAO,SAAS,CAAC;AAAA,IACrD,MAAM,mBAAmB,OAAO,WAAW,MAAM,gBAAgB,IAAI;AAAA,EACvE,CAAC;AAID,QAAM,QAA6B,CAAC;AACpC,MAAI,KAAK,IAAI;AACX,UAAM,QAAQ,EAAE,MAAM,OAAO,CAAC,CAAC,IAAI;AAAA,MACjC,UAAU,KAAK,KAAK;AAAA,MACpB,QAAQ,KAAK,KAAK;AAAA,MAClB,QAAQ;AAAA,MACR,OAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,MAAM,oBAAoB,QAAQ,IAAI;AACxC,UAAM,WAAW,MAAM,gBAAgB,CAAC,IAAI;AAAA,MAC1C,UAAU,CAAC,OAAO,IAAI;AAAA,MACtB,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,OAAO;AAAA,IACT;AAAA,EACF;AAEA,QAAM,eAAoC;AAAA,IACxC,QAAQ,OAAO,KAAK,OAAO,OAAO;AAAA,IAClC;AAAA,EACF;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,SAAS,eAAe,OAAO;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA,MAEA,8BAAC,eAAa,GAAG,OAAO;AAAA;AAAA,EAC1B;AAEJ;","names":[]}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@fewchurs/next",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A server-rendered Fewchurs feature-request board for Next.js, with the API key kept on the server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",
7
- "homepage": "https://www.fewchurs.shop",
7
+ "homepage": "https://www.fewchurs.com",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/fewchurs/fewchurs-react.git",
@@ -86,7 +86,7 @@
86
86
  },
87
87
  "dependencies": {
88
88
  "server-only": "^0.0.1",
89
- "@fewchurs/react": "^1.0.0"
89
+ "@fewchurs/react": "^1.1.0"
90
90
  },
91
91
  "peerDependencies": {
92
92
  "next": ">=14",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/handler.ts","../src/config.ts"],"sourcesContent":["import \"server-only\";\nimport { createDeviceId } from \"@fewchurs/react/core\";\nimport { resolveApiKey, resolveApiPath, resolveBaseUrl } from \"./config\";\nimport { DEVICE_ID_COOKIE, DEVICE_ID_MAX_AGE, SDK_HEADER } from \"./constants\";\nimport type { DeviceIdResolver } from \"./identity\";\n\nexport interface HandlerOptions {\n apiKey?: string;\n baseUrl?: string;\n /** Where the handler is mounted, when it is not `/api/fewchurs`. */\n apiPath?: string;\n /** Return a stable, hashed id for the signed-in visitor so votes follow the account. */\n deviceId?: DeviceIdResolver;\n}\n\ntype RouteParams = { route?: string[] };\n\n/** What Next passes: a Promise in 15, a plain object in 14. Both are awaited the same way. */\ntype IncomingContext = { params?: RouteParams | Promise<RouteParams> };\n\n// Next 15's route check wants a required second argument whose `params` is a Promise. The\n// handler itself takes `IncomingContext`, so Next 14's plain object still works at runtime.\ntype RouteContext = { params: Promise<RouteParams> };\n\nexport type RouteHandler = (request: Request, context: RouteContext) => Promise<Response>;\n\n/** Only the endpoints the board needs: everything else is a 404, not a proxy to the API. */\nconst ALLOWED: Array<{ method: string; pattern: RegExp }> = [\n { method: \"GET\", pattern: /^config$/ },\n { method: \"GET\", pattern: /^features$/ },\n { method: \"POST\", pattern: /^features$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/vote$/ },\n { method: \"POST\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"DELETE\", pattern: /^features\\/[^/]+\\/follow$/ },\n { method: \"GET\", pattern: /^features\\/[^/]+\\/comments$/ },\n { method: \"POST\", pattern: /^comments$/ },\n];\n\nconst DEVICE_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;\n\nexport function createHandler(options: HandlerOptions = {}): {\n GET: RouteHandler;\n POST: RouteHandler;\n DELETE: RouteHandler;\n} {\n async function handle(request: Request, context?: IncomingContext): Promise<Response> {\n const path = await routePath(request, context, resolveApiPath(options.apiPath));\n\n if (!ALLOWED.some((route) => route.method === request.method && route.pattern.test(path))) {\n return json({ error: { code: \"not_found\", message: \"Unknown Fewchurs route.\" } }, 404);\n }\n\n const apiKey = resolveApiKey(options.apiKey);\n if (!apiKey) {\n console.error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY, or pass apiKey to createHandler().\",\n );\n return json({ error: { code: \"internal\", message: \"Fewchurs is not configured.\" } }, 500);\n }\n\n const cookieValue = readCookie(request, DEVICE_ID_COOKIE);\n const custom = options.deviceId ? await options.deviceId() : null;\n const deviceId = custom ?? cookieValue ?? claimedDeviceId(request) ?? createDeviceId();\n\n const target = `${resolveBaseUrl(options.baseUrl)}/${path}${new URL(request.url).search}`;\n const headers: Record<string, string> = {\n Authorization: `Bearer ${apiKey}`,\n \"X-Device-Id\": deviceId,\n \"X-Fewchurs-Sdk\": request.headers.get(\"x-fewchurs-sdk\") ?? SDK_HEADER,\n Accept: \"application/json\",\n };\n const language = request.headers.get(\"accept-language\");\n if (language) headers[\"Accept-Language\"] = language;\n\n const body = request.method === \"GET\" ? undefined : await request.text();\n if (body) headers[\"Content-Type\"] = \"application/json\";\n\n let upstream: Response;\n try {\n upstream = await fetch(target, { method: request.method, headers, body, cache: \"no-store\" });\n } catch {\n return json({ error: { code: \"internal\", message: \"Could not reach Fewchurs.\" } }, 502);\n }\n\n const response = new Response(await upstream.text(), {\n status: upstream.status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n\n // A visitor keeps the same id across requests without the browser ever seeing the key,\n // and an httpOnly cookie means page scripts cannot read or change it either.\n if (!custom && deviceId !== cookieValue) {\n response.headers.append(\"Set-Cookie\", serializeCookie(deviceId));\n }\n\n return response;\n }\n\n return { GET: handle, POST: handle, DELETE: handle };\n}\n\nconst defaultHandler = createHandler();\n\nexport const GET = defaultHandler.GET;\nexport const POST = defaultHandler.POST;\nexport const DELETE = defaultHandler.DELETE;\n\nasync function routePath(\n request: Request,\n context: IncomingContext | undefined,\n apiPath: string,\n): Promise<string> {\n const params = await context?.params;\n if (params?.route?.length) return params.route.map(encodeURIComponent).join(\"/\");\n\n const { pathname } = new URL(request.url);\n const index = pathname.indexOf(apiPath);\n return index === -1 ? \"\" : pathname.slice(index + apiPath.length).replace(/^\\//, \"\");\n}\n\n/**\n * The first request from a server-rendered board carries the id the page was rendered with,\n * so adopting it keeps the server's `hasVoted` answers and the browser's in agreement.\n */\nfunction claimedDeviceId(request: Request): string | null {\n const claimed = request.headers.get(\"x-device-id\");\n return claimed && DEVICE_ID_PATTERN.test(claimed) ? claimed : null;\n}\n\nfunction readCookie(request: Request, name: string): string | null {\n const header = request.headers.get(\"cookie\");\n if (!header) return null;\n const match = header.match(new RegExp(`(?:^|;\\\\s*)${name}=([^;]*)`));\n return match?.[1] ? decodeURIComponent(match[1]) : null;\n}\n\nfunction serializeCookie(deviceId: string): string {\n const parts = [\n `${DEVICE_ID_COOKIE}=${encodeURIComponent(deviceId)}`,\n \"Path=/\",\n `Max-Age=${DEVICE_ID_MAX_AGE}`,\n \"HttpOnly\",\n \"SameSite=Lax\",\n ];\n if (process.env.NODE_ENV === \"production\") parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nfunction json(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\", \"Cache-Control\": \"no-store\" },\n });\n}\n","import \"server-only\";\nimport { createFewchursClient, DEFAULT_BASE_URL, type FewchursClient } from \"@fewchurs/react/core\";\n\nexport const DEFAULT_API_PATH = \"/api/fewchurs\";\n\nexport interface ServerConfig {\n apiKey?: string;\n baseUrl?: string;\n language?: string;\n}\n\nexport function resolveApiKey(apiKey?: string): string | undefined {\n return apiKey ?? process.env.FEWCHURS_API_KEY;\n}\n\nexport function resolveBaseUrl(baseUrl?: string): string {\n return baseUrl ?? process.env.FEWCHURS_BASE_URL ?? DEFAULT_BASE_URL;\n}\n\nexport function resolveApiPath(apiPath?: string): string {\n return (apiPath ?? process.env.FEWCHURS_API_PATH ?? DEFAULT_API_PATH).replace(/\\/$/, \"\");\n}\n\nexport function serverClient(deviceId: string | null, config: ServerConfig = {}): FewchursClient {\n const apiKey = resolveApiKey(config.apiKey);\n if (!apiKey) {\n throw new Error(\n \"[fewchurs] No API key. Set FEWCHURS_API_KEY in your environment, or pass apiKey to the server component or createHandler().\",\n );\n }\n\n return createFewchursClient({\n apiKey,\n baseUrl: resolveBaseUrl(config.baseUrl),\n deviceId,\n language: config.language,\n });\n}\n"],"mappings":"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","names":[]}