@pixelatlarge/peanut-gallery-relay 0.0.0-stage → 0.1.1
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 +9 -0
- package/README.md +17 -2
- package/dist/chunk-RS5CY75G.js +100 -0
- package/dist/index.cjs +124 -0
- package/dist/index.d.cts +36 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +6 -0
- package/dist/node.cjs +168 -0
- package/dist/node.d.cts +13 -0
- package/dist/node.d.ts +13 -0
- package/dist/node.js +46 -0
- package/package.json +52 -4
- package/templates/cloudflare-worker.ts +16 -0
- package/templates/netlify-function.ts +11 -0
- package/templates/node-server.mjs +3 -0
- package/templates/vercel-api-peanut-gallery.ts +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anna Poirier
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pixelatlarge/peanut-gallery-relay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A tiny serverless function that turns [Peanut Gallery](https://github.com/PixelAtLarge/peanut-gallery) widget submissions into GitHub issues, so reviewers don't need a GitHub account. It validates the payload, checks the origin and creates the issue with your token. The token never reaches the browser.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// api/peanut-gallery.ts (Vercel)
|
|
7
|
+
import { createRelayHandler } from '@pixelatlarge/peanut-gallery-relay';
|
|
8
|
+
|
|
9
|
+
export default createRelayHandler({
|
|
10
|
+
githubToken: process.env.PEANUT_GALLERY_TOKEN!, // fine-grained token: Issues read/write on one repo
|
|
11
|
+
repo: process.env.PEANUT_GALLERY_REPO!,
|
|
12
|
+
allowedOrigins: ['https://proto.example.com'],
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Templates for Cloudflare Workers, Netlify Functions and plain Node are in `templates/`. Then set `data-relay-url` on the widget.
|
|
17
|
+
|
|
18
|
+
MIT licensed.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { sessionToIssue, LABELS } from "@pixelatlarge/peanut-gallery-core";
|
|
3
|
+
function createRelayHandler(opts) {
|
|
4
|
+
if (!opts.githubToken) throw new Error("relay: githubToken is required");
|
|
5
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(opts.repo)) throw new Error('relay: repo must be "owner/name"');
|
|
6
|
+
const f = opts.fetch ?? fetch;
|
|
7
|
+
const apiBase = (opts.apiBase ?? "https://api.github.com").replace(/\/$/, "");
|
|
8
|
+
const allowed = opts.allowedOrigins ?? ["*"];
|
|
9
|
+
const max = opts.maxAnnotations ?? 100;
|
|
10
|
+
const cors = (origin) => {
|
|
11
|
+
const ok = allowed.includes("*") || origin !== null && allowed.includes(origin);
|
|
12
|
+
return {
|
|
13
|
+
ok,
|
|
14
|
+
headers: {
|
|
15
|
+
"access-control-allow-origin": ok ? allowed.includes("*") ? "*" : origin : "null",
|
|
16
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
17
|
+
"access-control-allow-headers": "content-type, x-peanut-gallery-key",
|
|
18
|
+
"access-control-max-age": "86400",
|
|
19
|
+
vary: "origin"
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const baseLabels = opts.labels ?? [LABELS.root, LABELS.feedback];
|
|
24
|
+
const permitted = /* @__PURE__ */ new Set([...baseLabels, ...opts.allowedLabels ?? []]);
|
|
25
|
+
return async function handle(req) {
|
|
26
|
+
try {
|
|
27
|
+
return await handleInner(req);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const c = cors(req.headers.get("origin"));
|
|
30
|
+
return new Response(JSON.stringify({ error: "relay error", detail: String(err?.message ?? err).slice(0, 200) }), {
|
|
31
|
+
status: 500,
|
|
32
|
+
headers: { "content-type": "application/json", ...c.headers }
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
async function handleInner(req) {
|
|
37
|
+
const origin = req.headers.get("origin");
|
|
38
|
+
const c = cors(origin);
|
|
39
|
+
const json = (status, body2) => new Response(JSON.stringify(body2), { status, headers: { "content-type": "application/json", ...c.headers } });
|
|
40
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: c.headers });
|
|
41
|
+
if (req.method !== "POST") return json(405, { error: "method not allowed" });
|
|
42
|
+
if (!c.ok) return json(403, { error: "origin not allowed" });
|
|
43
|
+
if (opts.sharedKey && req.headers.get("x-peanut-gallery-key") !== opts.sharedKey) return json(401, { error: "bad key" });
|
|
44
|
+
let payload;
|
|
45
|
+
try {
|
|
46
|
+
payload = await req.json();
|
|
47
|
+
} catch {
|
|
48
|
+
return json(400, { error: "invalid json" });
|
|
49
|
+
}
|
|
50
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return json(400, { error: "expected a JSON object" });
|
|
51
|
+
const session = payload.session;
|
|
52
|
+
const problem = validate(session, max);
|
|
53
|
+
if (problem) return json(400, { error: problem });
|
|
54
|
+
const { title, body } = sessionToIssue(session);
|
|
55
|
+
const asked = Array.isArray(payload.labels) ? payload.labels.filter((l) => typeof l === "string") : [];
|
|
56
|
+
const labels = Array.from(/* @__PURE__ */ new Set([...baseLabels, ...asked.filter((l) => permitted.has(l))]));
|
|
57
|
+
let res;
|
|
58
|
+
try {
|
|
59
|
+
res = await f(`${apiBase}/repos/${opts.repo}/issues`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
authorization: `Bearer ${opts.githubToken}`,
|
|
63
|
+
accept: "application/vnd.github+json",
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
"user-agent": "peanut-gallery-relay",
|
|
66
|
+
"x-github-api-version": "2022-11-28"
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ title, body, labels })
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return json(502, { error: "could not reach GitHub", detail: String(err?.message ?? err).slice(0, 200) });
|
|
72
|
+
}
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text().catch(() => "");
|
|
75
|
+
return json(502, { error: `github ${res.status}`, detail: text.slice(0, 500) });
|
|
76
|
+
}
|
|
77
|
+
const issue = await res.json();
|
|
78
|
+
const result = { issueUrl: issue.html_url, issueNumber: issue.number };
|
|
79
|
+
return json(201, result);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function validate(s, max) {
|
|
83
|
+
if (!s || typeof s !== "object") return "missing session";
|
|
84
|
+
if (typeof s.id !== "string" || typeof s.prototypeUrl !== "string") return "malformed session";
|
|
85
|
+
if (!Array.isArray(s.annotations) || s.annotations.length === 0) return "no annotations";
|
|
86
|
+
if (s.annotations.length > max) return `too many annotations (max ${max})`;
|
|
87
|
+
for (const a of s.annotations) {
|
|
88
|
+
if (!a || typeof a !== "object") return "annotation invalid";
|
|
89
|
+
if (typeof a.text !== "string" || a.text.length === 0 || a.text.length > 5e3) return "annotation text invalid";
|
|
90
|
+
if (!a.anchor || typeof a.anchor.selector !== "string" || a.anchor.selector.length > 2e3) return "anchor invalid";
|
|
91
|
+
if (typeof a.pageUrl !== "string" || a.pageUrl.length > 2e3) return "pageUrl invalid";
|
|
92
|
+
if (a.steps !== void 0 && (!Array.isArray(a.steps) || a.steps.length > 10 || a.steps.some((st) => !st || typeof st.selector !== "string" || st.selector.length > 2e3))) return "steps invalid";
|
|
93
|
+
if (a.viewport !== void 0 && (typeof a.viewport !== "object" || !Number.isFinite(a.viewport?.width) || !Number.isFinite(a.viewport?.height))) return "viewport invalid";
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
createRelayHandler
|
|
100
|
+
};
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createRelayHandler: () => createRelayHandler
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
var import_peanut_gallery_core = require("@pixelatlarge/peanut-gallery-core");
|
|
27
|
+
function createRelayHandler(opts) {
|
|
28
|
+
if (!opts.githubToken) throw new Error("relay: githubToken is required");
|
|
29
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(opts.repo)) throw new Error('relay: repo must be "owner/name"');
|
|
30
|
+
const f = opts.fetch ?? fetch;
|
|
31
|
+
const apiBase = (opts.apiBase ?? "https://api.github.com").replace(/\/$/, "");
|
|
32
|
+
const allowed = opts.allowedOrigins ?? ["*"];
|
|
33
|
+
const max = opts.maxAnnotations ?? 100;
|
|
34
|
+
const cors = (origin) => {
|
|
35
|
+
const ok = allowed.includes("*") || origin !== null && allowed.includes(origin);
|
|
36
|
+
return {
|
|
37
|
+
ok,
|
|
38
|
+
headers: {
|
|
39
|
+
"access-control-allow-origin": ok ? allowed.includes("*") ? "*" : origin : "null",
|
|
40
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
41
|
+
"access-control-allow-headers": "content-type, x-peanut-gallery-key",
|
|
42
|
+
"access-control-max-age": "86400",
|
|
43
|
+
vary: "origin"
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const baseLabels = opts.labels ?? [import_peanut_gallery_core.LABELS.root, import_peanut_gallery_core.LABELS.feedback];
|
|
48
|
+
const permitted = /* @__PURE__ */ new Set([...baseLabels, ...opts.allowedLabels ?? []]);
|
|
49
|
+
return async function handle(req) {
|
|
50
|
+
try {
|
|
51
|
+
return await handleInner(req);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const c = cors(req.headers.get("origin"));
|
|
54
|
+
return new Response(JSON.stringify({ error: "relay error", detail: String(err?.message ?? err).slice(0, 200) }), {
|
|
55
|
+
status: 500,
|
|
56
|
+
headers: { "content-type": "application/json", ...c.headers }
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
async function handleInner(req) {
|
|
61
|
+
const origin = req.headers.get("origin");
|
|
62
|
+
const c = cors(origin);
|
|
63
|
+
const json = (status, body2) => new Response(JSON.stringify(body2), { status, headers: { "content-type": "application/json", ...c.headers } });
|
|
64
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: c.headers });
|
|
65
|
+
if (req.method !== "POST") return json(405, { error: "method not allowed" });
|
|
66
|
+
if (!c.ok) return json(403, { error: "origin not allowed" });
|
|
67
|
+
if (opts.sharedKey && req.headers.get("x-peanut-gallery-key") !== opts.sharedKey) return json(401, { error: "bad key" });
|
|
68
|
+
let payload;
|
|
69
|
+
try {
|
|
70
|
+
payload = await req.json();
|
|
71
|
+
} catch {
|
|
72
|
+
return json(400, { error: "invalid json" });
|
|
73
|
+
}
|
|
74
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return json(400, { error: "expected a JSON object" });
|
|
75
|
+
const session = payload.session;
|
|
76
|
+
const problem = validate(session, max);
|
|
77
|
+
if (problem) return json(400, { error: problem });
|
|
78
|
+
const { title, body } = (0, import_peanut_gallery_core.sessionToIssue)(session);
|
|
79
|
+
const asked = Array.isArray(payload.labels) ? payload.labels.filter((l) => typeof l === "string") : [];
|
|
80
|
+
const labels = Array.from(/* @__PURE__ */ new Set([...baseLabels, ...asked.filter((l) => permitted.has(l))]));
|
|
81
|
+
let res;
|
|
82
|
+
try {
|
|
83
|
+
res = await f(`${apiBase}/repos/${opts.repo}/issues`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
authorization: `Bearer ${opts.githubToken}`,
|
|
87
|
+
accept: "application/vnd.github+json",
|
|
88
|
+
"content-type": "application/json",
|
|
89
|
+
"user-agent": "peanut-gallery-relay",
|
|
90
|
+
"x-github-api-version": "2022-11-28"
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ title, body, labels })
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return json(502, { error: "could not reach GitHub", detail: String(err?.message ?? err).slice(0, 200) });
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const text = await res.text().catch(() => "");
|
|
99
|
+
return json(502, { error: `github ${res.status}`, detail: text.slice(0, 500) });
|
|
100
|
+
}
|
|
101
|
+
const issue = await res.json();
|
|
102
|
+
const result = { issueUrl: issue.html_url, issueNumber: issue.number };
|
|
103
|
+
return json(201, result);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function validate(s, max) {
|
|
107
|
+
if (!s || typeof s !== "object") return "missing session";
|
|
108
|
+
if (typeof s.id !== "string" || typeof s.prototypeUrl !== "string") return "malformed session";
|
|
109
|
+
if (!Array.isArray(s.annotations) || s.annotations.length === 0) return "no annotations";
|
|
110
|
+
if (s.annotations.length > max) return `too many annotations (max ${max})`;
|
|
111
|
+
for (const a of s.annotations) {
|
|
112
|
+
if (!a || typeof a !== "object") return "annotation invalid";
|
|
113
|
+
if (typeof a.text !== "string" || a.text.length === 0 || a.text.length > 5e3) return "annotation text invalid";
|
|
114
|
+
if (!a.anchor || typeof a.anchor.selector !== "string" || a.anchor.selector.length > 2e3) return "anchor invalid";
|
|
115
|
+
if (typeof a.pageUrl !== "string" || a.pageUrl.length > 2e3) return "pageUrl invalid";
|
|
116
|
+
if (a.steps !== void 0 && (!Array.isArray(a.steps) || a.steps.length > 10 || a.steps.some((st) => !st || typeof st.selector !== "string" || st.selector.length > 2e3))) return "steps invalid";
|
|
117
|
+
if (a.viewport !== void 0 && (typeof a.viewport !== "object" || !Number.isFinite(a.viewport?.width) || !Number.isFinite(a.viewport?.height))) return "viewport invalid";
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
122
|
+
0 && (module.exports = {
|
|
123
|
+
createRelayHandler
|
|
124
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface RelayOptions {
|
|
2
|
+
/** Fine-grained PAT or GitHub App installation token with Issues: write on the repo. */
|
|
3
|
+
githubToken: string;
|
|
4
|
+
/** "owner/name". The relay only ever writes to this repo. */
|
|
5
|
+
repo: string;
|
|
6
|
+
/** Origins allowed to call the relay (the hosted prototype). "*" allows any. */
|
|
7
|
+
allowedOrigins?: string[];
|
|
8
|
+
/** Optional shared secret; widget must send `x-peanut-gallery-key`. Use when the prototype is public. */
|
|
9
|
+
sharedKey?: string;
|
|
10
|
+
/** Labels always applied. Defaults to peanut-gallery + peanut-gallery:feedback. */
|
|
11
|
+
labels?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Extra labels a submission may ask for (the widget's `labels` option). Anything else the
|
|
14
|
+
* client sends is dropped, so the public endpoint can't set workflow labels such as
|
|
15
|
+
* peanut-gallery:accepted or create junk labels. Default: none beyond `labels`.
|
|
16
|
+
*/
|
|
17
|
+
allowedLabels?: string[];
|
|
18
|
+
/** Max annotations accepted in one submission. */
|
|
19
|
+
maxAnnotations?: number;
|
|
20
|
+
/** Override fetch (tests). */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
/** GitHub API base (GHES). */
|
|
23
|
+
apiBase?: string;
|
|
24
|
+
}
|
|
25
|
+
interface RelayResult {
|
|
26
|
+
issueUrl: string;
|
|
27
|
+
issueNumber: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Returns a Web-standard `(request: Request) => Promise<Response>` handler.
|
|
31
|
+
* Works as-is on Cloudflare Workers, Vercel Edge/Node (web runtime), Deno, Bun, Netlify.
|
|
32
|
+
* For Node's http module, see `@pixelatlarge/peanut-gallery-relay/node`.
|
|
33
|
+
*/
|
|
34
|
+
declare function createRelayHandler(opts: RelayOptions): (req: Request) => Promise<Response>;
|
|
35
|
+
|
|
36
|
+
export { type RelayOptions, type RelayResult, createRelayHandler };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface RelayOptions {
|
|
2
|
+
/** Fine-grained PAT or GitHub App installation token with Issues: write on the repo. */
|
|
3
|
+
githubToken: string;
|
|
4
|
+
/** "owner/name". The relay only ever writes to this repo. */
|
|
5
|
+
repo: string;
|
|
6
|
+
/** Origins allowed to call the relay (the hosted prototype). "*" allows any. */
|
|
7
|
+
allowedOrigins?: string[];
|
|
8
|
+
/** Optional shared secret; widget must send `x-peanut-gallery-key`. Use when the prototype is public. */
|
|
9
|
+
sharedKey?: string;
|
|
10
|
+
/** Labels always applied. Defaults to peanut-gallery + peanut-gallery:feedback. */
|
|
11
|
+
labels?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Extra labels a submission may ask for (the widget's `labels` option). Anything else the
|
|
14
|
+
* client sends is dropped, so the public endpoint can't set workflow labels such as
|
|
15
|
+
* peanut-gallery:accepted or create junk labels. Default: none beyond `labels`.
|
|
16
|
+
*/
|
|
17
|
+
allowedLabels?: string[];
|
|
18
|
+
/** Max annotations accepted in one submission. */
|
|
19
|
+
maxAnnotations?: number;
|
|
20
|
+
/** Override fetch (tests). */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
/** GitHub API base (GHES). */
|
|
23
|
+
apiBase?: string;
|
|
24
|
+
}
|
|
25
|
+
interface RelayResult {
|
|
26
|
+
issueUrl: string;
|
|
27
|
+
issueNumber: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Returns a Web-standard `(request: Request) => Promise<Response>` handler.
|
|
31
|
+
* Works as-is on Cloudflare Workers, Vercel Edge/Node (web runtime), Deno, Bun, Netlify.
|
|
32
|
+
* For Node's http module, see `@pixelatlarge/peanut-gallery-relay/node`.
|
|
33
|
+
*/
|
|
34
|
+
declare function createRelayHandler(opts: RelayOptions): (req: Request) => Promise<Response>;
|
|
35
|
+
|
|
36
|
+
export { type RelayOptions, type RelayResult, createRelayHandler };
|
package/dist/index.js
ADDED
package/dist/node.cjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/node.ts
|
|
21
|
+
var node_exports = {};
|
|
22
|
+
__export(node_exports, {
|
|
23
|
+
createNodeHandler: () => createNodeHandler,
|
|
24
|
+
optionsFromEnv: () => optionsFromEnv,
|
|
25
|
+
serve: () => serve
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(node_exports);
|
|
28
|
+
var import_node_http = require("http");
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var import_peanut_gallery_core = require("@pixelatlarge/peanut-gallery-core");
|
|
32
|
+
function createRelayHandler(opts) {
|
|
33
|
+
if (!opts.githubToken) throw new Error("relay: githubToken is required");
|
|
34
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(opts.repo)) throw new Error('relay: repo must be "owner/name"');
|
|
35
|
+
const f = opts.fetch ?? fetch;
|
|
36
|
+
const apiBase = (opts.apiBase ?? "https://api.github.com").replace(/\/$/, "");
|
|
37
|
+
const allowed = opts.allowedOrigins ?? ["*"];
|
|
38
|
+
const max = opts.maxAnnotations ?? 100;
|
|
39
|
+
const cors = (origin) => {
|
|
40
|
+
const ok = allowed.includes("*") || origin !== null && allowed.includes(origin);
|
|
41
|
+
return {
|
|
42
|
+
ok,
|
|
43
|
+
headers: {
|
|
44
|
+
"access-control-allow-origin": ok ? allowed.includes("*") ? "*" : origin : "null",
|
|
45
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
46
|
+
"access-control-allow-headers": "content-type, x-peanut-gallery-key",
|
|
47
|
+
"access-control-max-age": "86400",
|
|
48
|
+
vary: "origin"
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const baseLabels = opts.labels ?? [import_peanut_gallery_core.LABELS.root, import_peanut_gallery_core.LABELS.feedback];
|
|
53
|
+
const permitted = /* @__PURE__ */ new Set([...baseLabels, ...opts.allowedLabels ?? []]);
|
|
54
|
+
return async function handle(req) {
|
|
55
|
+
try {
|
|
56
|
+
return await handleInner(req);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const c = cors(req.headers.get("origin"));
|
|
59
|
+
return new Response(JSON.stringify({ error: "relay error", detail: String(err?.message ?? err).slice(0, 200) }), {
|
|
60
|
+
status: 500,
|
|
61
|
+
headers: { "content-type": "application/json", ...c.headers }
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
async function handleInner(req) {
|
|
66
|
+
const origin = req.headers.get("origin");
|
|
67
|
+
const c = cors(origin);
|
|
68
|
+
const json = (status, body2) => new Response(JSON.stringify(body2), { status, headers: { "content-type": "application/json", ...c.headers } });
|
|
69
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: c.headers });
|
|
70
|
+
if (req.method !== "POST") return json(405, { error: "method not allowed" });
|
|
71
|
+
if (!c.ok) return json(403, { error: "origin not allowed" });
|
|
72
|
+
if (opts.sharedKey && req.headers.get("x-peanut-gallery-key") !== opts.sharedKey) return json(401, { error: "bad key" });
|
|
73
|
+
let payload;
|
|
74
|
+
try {
|
|
75
|
+
payload = await req.json();
|
|
76
|
+
} catch {
|
|
77
|
+
return json(400, { error: "invalid json" });
|
|
78
|
+
}
|
|
79
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return json(400, { error: "expected a JSON object" });
|
|
80
|
+
const session = payload.session;
|
|
81
|
+
const problem = validate(session, max);
|
|
82
|
+
if (problem) return json(400, { error: problem });
|
|
83
|
+
const { title, body } = (0, import_peanut_gallery_core.sessionToIssue)(session);
|
|
84
|
+
const asked = Array.isArray(payload.labels) ? payload.labels.filter((l) => typeof l === "string") : [];
|
|
85
|
+
const labels = Array.from(/* @__PURE__ */ new Set([...baseLabels, ...asked.filter((l) => permitted.has(l))]));
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
res = await f(`${apiBase}/repos/${opts.repo}/issues`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: {
|
|
91
|
+
authorization: `Bearer ${opts.githubToken}`,
|
|
92
|
+
accept: "application/vnd.github+json",
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
"user-agent": "peanut-gallery-relay",
|
|
95
|
+
"x-github-api-version": "2022-11-28"
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ title, body, labels })
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return json(502, { error: "could not reach GitHub", detail: String(err?.message ?? err).slice(0, 200) });
|
|
101
|
+
}
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
const text = await res.text().catch(() => "");
|
|
104
|
+
return json(502, { error: `github ${res.status}`, detail: text.slice(0, 500) });
|
|
105
|
+
}
|
|
106
|
+
const issue = await res.json();
|
|
107
|
+
const result = { issueUrl: issue.html_url, issueNumber: issue.number };
|
|
108
|
+
return json(201, result);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function validate(s, max) {
|
|
112
|
+
if (!s || typeof s !== "object") return "missing session";
|
|
113
|
+
if (typeof s.id !== "string" || typeof s.prototypeUrl !== "string") return "malformed session";
|
|
114
|
+
if (!Array.isArray(s.annotations) || s.annotations.length === 0) return "no annotations";
|
|
115
|
+
if (s.annotations.length > max) return `too many annotations (max ${max})`;
|
|
116
|
+
for (const a of s.annotations) {
|
|
117
|
+
if (!a || typeof a !== "object") return "annotation invalid";
|
|
118
|
+
if (typeof a.text !== "string" || a.text.length === 0 || a.text.length > 5e3) return "annotation text invalid";
|
|
119
|
+
if (!a.anchor || typeof a.anchor.selector !== "string" || a.anchor.selector.length > 2e3) return "anchor invalid";
|
|
120
|
+
if (typeof a.pageUrl !== "string" || a.pageUrl.length > 2e3) return "pageUrl invalid";
|
|
121
|
+
if (a.steps !== void 0 && (!Array.isArray(a.steps) || a.steps.length > 10 || a.steps.some((st) => !st || typeof st.selector !== "string" || st.selector.length > 2e3))) return "steps invalid";
|
|
122
|
+
if (a.viewport !== void 0 && (typeof a.viewport !== "object" || !Number.isFinite(a.viewport?.width) || !Number.isFinite(a.viewport?.height))) return "viewport invalid";
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/node.ts
|
|
128
|
+
function createNodeHandler(opts) {
|
|
129
|
+
const handle = createRelayHandler(opts);
|
|
130
|
+
return async (req, res) => {
|
|
131
|
+
const chunks = [];
|
|
132
|
+
for await (const c of req) chunks.push(c);
|
|
133
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
|
134
|
+
const headers = new Headers();
|
|
135
|
+
for (const [k, v] of Object.entries(req.headers)) if (typeof v === "string") headers.set(k, v);
|
|
136
|
+
const request = new Request(url, {
|
|
137
|
+
method: req.method,
|
|
138
|
+
headers,
|
|
139
|
+
body: req.method === "GET" || req.method === "HEAD" ? void 0 : Buffer.concat(chunks)
|
|
140
|
+
});
|
|
141
|
+
const response = await handle(request);
|
|
142
|
+
res.statusCode = response.status;
|
|
143
|
+
response.headers.forEach((v, k) => res.setHeader(k, v));
|
|
144
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function serve(opts) {
|
|
148
|
+
const server = (0, import_node_http.createServer)(createNodeHandler(opts));
|
|
149
|
+
const port = opts.port ?? Number(process.env.PORT ?? 8787);
|
|
150
|
+
server.listen(port, () => console.log(`peanut-gallery relay listening on :${port} \u2192 ${opts.repo}`));
|
|
151
|
+
return server;
|
|
152
|
+
}
|
|
153
|
+
function optionsFromEnv(env = process.env) {
|
|
154
|
+
const githubToken = env.PEANUT_GALLERY_TOKEN ?? env.GITHUB_TOKEN ?? "";
|
|
155
|
+
const repo = env.PEANUT_GALLERY_REPO ?? "";
|
|
156
|
+
return {
|
|
157
|
+
githubToken,
|
|
158
|
+
repo,
|
|
159
|
+
allowedOrigins: env.PEANUT_GALLERY_ORIGINS?.split(",").map((s) => s.trim()),
|
|
160
|
+
sharedKey: env.PEANUT_GALLERY_KEY
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
164
|
+
0 && (module.exports = {
|
|
165
|
+
createNodeHandler,
|
|
166
|
+
optionsFromEnv,
|
|
167
|
+
serve
|
|
168
|
+
});
|
package/dist/node.d.cts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { RelayOptions } from './index.cjs';
|
|
4
|
+
|
|
5
|
+
/** Adapt the web handler to Node's http module (Express-compatible signature). */
|
|
6
|
+
declare function createNodeHandler(opts: RelayOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
7
|
+
/** Standalone server: `PEANUT_GALLERY_TOKEN=... PEANUT_GALLERY_REPO=owner/name node -e "..."` */
|
|
8
|
+
declare function serve(opts: RelayOptions & {
|
|
9
|
+
port?: number;
|
|
10
|
+
}): http.Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
11
|
+
declare function optionsFromEnv(env?: NodeJS.ProcessEnv): RelayOptions;
|
|
12
|
+
|
|
13
|
+
export { createNodeHandler, optionsFromEnv, serve };
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { RelayOptions } from './index.js';
|
|
4
|
+
|
|
5
|
+
/** Adapt the web handler to Node's http module (Express-compatible signature). */
|
|
6
|
+
declare function createNodeHandler(opts: RelayOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
7
|
+
/** Standalone server: `PEANUT_GALLERY_TOKEN=... PEANUT_GALLERY_REPO=owner/name node -e "..."` */
|
|
8
|
+
declare function serve(opts: RelayOptions & {
|
|
9
|
+
port?: number;
|
|
10
|
+
}): http.Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
11
|
+
declare function optionsFromEnv(env?: NodeJS.ProcessEnv): RelayOptions;
|
|
12
|
+
|
|
13
|
+
export { createNodeHandler, optionsFromEnv, serve };
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRelayHandler
|
|
3
|
+
} from "./chunk-RS5CY75G.js";
|
|
4
|
+
|
|
5
|
+
// src/node.ts
|
|
6
|
+
import { createServer } from "http";
|
|
7
|
+
function createNodeHandler(opts) {
|
|
8
|
+
const handle = createRelayHandler(opts);
|
|
9
|
+
return async (req, res) => {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for await (const c of req) chunks.push(c);
|
|
12
|
+
const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
|
|
13
|
+
const headers = new Headers();
|
|
14
|
+
for (const [k, v] of Object.entries(req.headers)) if (typeof v === "string") headers.set(k, v);
|
|
15
|
+
const request = new Request(url, {
|
|
16
|
+
method: req.method,
|
|
17
|
+
headers,
|
|
18
|
+
body: req.method === "GET" || req.method === "HEAD" ? void 0 : Buffer.concat(chunks)
|
|
19
|
+
});
|
|
20
|
+
const response = await handle(request);
|
|
21
|
+
res.statusCode = response.status;
|
|
22
|
+
response.headers.forEach((v, k) => res.setHeader(k, v));
|
|
23
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function serve(opts) {
|
|
27
|
+
const server = createServer(createNodeHandler(opts));
|
|
28
|
+
const port = opts.port ?? Number(process.env.PORT ?? 8787);
|
|
29
|
+
server.listen(port, () => console.log(`peanut-gallery relay listening on :${port} \u2192 ${opts.repo}`));
|
|
30
|
+
return server;
|
|
31
|
+
}
|
|
32
|
+
function optionsFromEnv(env = process.env) {
|
|
33
|
+
const githubToken = env.PEANUT_GALLERY_TOKEN ?? env.GITHUB_TOKEN ?? "";
|
|
34
|
+
const repo = env.PEANUT_GALLERY_REPO ?? "";
|
|
35
|
+
return {
|
|
36
|
+
githubToken,
|
|
37
|
+
repo,
|
|
38
|
+
allowedOrigins: env.PEANUT_GALLERY_ORIGINS?.split(",").map((s) => s.trim()),
|
|
39
|
+
sharedKey: env.PEANUT_GALLERY_KEY
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
createNodeHandler,
|
|
44
|
+
optionsFromEnv,
|
|
45
|
+
serve
|
|
46
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixelatlarge/peanut-gallery-relay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Tiny serverless relay that turns peanut-gallery widget submissions into GitHub Issues. Deploy to Vercel, Cloudflare, Netlify or Node.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"github-issues",
|
|
7
|
+
"serverless",
|
|
8
|
+
"prototype",
|
|
9
|
+
"feedback"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/PixelAtLarge/peanut-gallery.git",
|
|
14
|
+
"directory": "packages/relay"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/PixelAtLarge/peanut-gallery#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/PixelAtLarge/peanut-gallery/issues"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./dist/index.cjs",
|
|
22
|
+
"module": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"require": "./dist/index.cjs"
|
|
29
|
+
},
|
|
30
|
+
"./node": {
|
|
31
|
+
"types": "./dist/node.d.ts",
|
|
32
|
+
"import": "./dist/node.js",
|
|
33
|
+
"require": "./dist/node.cjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"templates",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup src/index.ts src/node.ts --format esm,cjs --dts --clean",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@pixelatlarge/peanut-gallery-core": "0.1.1"
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Cloudflare Workers: `wrangler secret put PEANUT_GALLERY_TOKEN`, set PEANUT_GALLERY_REPO etc. as vars.
|
|
2
|
+
import { createRelayHandler } from '@pixelatlarge/peanut-gallery-relay';
|
|
3
|
+
|
|
4
|
+
interface Env { PEANUT_GALLERY_TOKEN: string; PEANUT_GALLERY_REPO: string; PEANUT_GALLERY_ORIGINS?: string; PEANUT_GALLERY_KEY?: string }
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
8
|
+
const handle = createRelayHandler({
|
|
9
|
+
githubToken: env.PEANUT_GALLERY_TOKEN,
|
|
10
|
+
repo: env.PEANUT_GALLERY_REPO,
|
|
11
|
+
allowedOrigins: env.PEANUT_GALLERY_ORIGINS?.split(','),
|
|
12
|
+
sharedKey: env.PEANUT_GALLERY_KEY,
|
|
13
|
+
});
|
|
14
|
+
return handle(request);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Netlify Functions v2: copy to netlify/functions/peanut-gallery.ts
|
|
2
|
+
import { createRelayHandler } from '@pixelatlarge/peanut-gallery-relay';
|
|
3
|
+
|
|
4
|
+
const handle = createRelayHandler({
|
|
5
|
+
githubToken: process.env.PEANUT_GALLERY_TOKEN!,
|
|
6
|
+
repo: process.env.PEANUT_GALLERY_REPO!,
|
|
7
|
+
allowedOrigins: process.env.PEANUT_GALLERY_ORIGINS?.split(','),
|
|
8
|
+
sharedKey: process.env.PEANUT_GALLERY_KEY,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export default async (req: Request) => handle(req);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Vercel: copy to api/peanut-gallery.ts. Set env PEANUT_GALLERY_TOKEN, PEANUT_GALLERY_REPO, PEANUT_GALLERY_ORIGINS, PEANUT_GALLERY_KEY.
|
|
2
|
+
import { createRelayHandler } from '@pixelatlarge/peanut-gallery-relay';
|
|
3
|
+
|
|
4
|
+
export const config = { runtime: 'edge' };
|
|
5
|
+
|
|
6
|
+
const handle = createRelayHandler({
|
|
7
|
+
githubToken: process.env.PEANUT_GALLERY_TOKEN!,
|
|
8
|
+
repo: process.env.PEANUT_GALLERY_REPO!,
|
|
9
|
+
allowedOrigins: process.env.PEANUT_GALLERY_ORIGINS?.split(','),
|
|
10
|
+
sharedKey: process.env.PEANUT_GALLERY_KEY,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export default handle;
|