Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 47 additions & 89 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,66 @@
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { Hono } from "hono";
import type { Context } from "hono";
import { serve } from "@hono/node-server";
import { trimTrailingSlash } from "hono/trailing-slash";
import type { ContentfulStatusCode } from "hono/utils/http-status";

const BASE = 'https://raw.githubusercontent.com/getsentry/sentry-for-ai/refs/heads/main';
const BASE = "https://raw.githubusercontent.com/getsentry/sentry-for-ai/refs/heads/main";

// Cache
interface CacheEntry {
body: string;
status: number;
fetchedAt: number;
}

const cache = new Map<string, CacheEntry>();
function buildSkillUrl(pathname: string): string | null {
if (!pathname.startsWith("/")) {
return null;
}

function getCache(key: string): CacheEntry | null {
const entry = cache.get(key);
if (!entry) return null;
const ttl = entry.status === 200 ? 5 * 60 * 1000 : 1 * 60 * 1000;
if (Date.now() - entry.fetchedAt > ttl) return null;
return entry;
}
const segments = pathname.slice(1).split("/");
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
return null;
}
if (segments[0] === "skills") {
return null;
}

function setCache(key: string, body: string, status: number): void {
cache.set(key, { body, status, fetchedAt: Date.now() });
return `${BASE}/skills${pathname}`;
}

// Category shortcuts — direct entry points for router skills
const CATEGORY_ROUTES: Record<string, string> = {
'/sdks': '/sentry-sdk-setup/SKILL.md',
'/workflows': '/sentry-workflow/SKILL.md',
'/features': '/sentry-feature-setup/SKILL.md',
};

// URL mapper
function mapPath(path: string): string | null {
if (path.includes('..')) return null;
if (path === '/' || path === '/SKILL_TREE.md') {
return `${BASE}/SKILL_TREE.md`;
}
// Category shortcuts resolve to their router skill
const redirect = CATEGORY_ROUTES[path];
if (redirect) {
return `${BASE}/skills${redirect}`;
async function proxyText(c: Context, url: string): Promise<Response> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const body = await res.text();
return c.text(body, res.status as ContentfulStatusCode, {
"Content-Type": "text/plain; charset=utf-8",
});
} catch {
return c.text("Bad Gateway", 502);
}
return `${BASE}/skills${path}`;
}

// App
const app = new Hono();
app.use(trimTrailingSlash({ alwaysRedirect: true }));

app.get("/", (c) => proxyText(c, `${BASE}/SKILL_TREE.md`));
app.get("/sdks", (c) => proxyText(c, `${BASE}/skills/sentry-sdk-setup/SKILL.md`));
app.get("/workflows", (c) => proxyText(c, `${BASE}/skills/sentry-workflow/SKILL.md`));
app.get("/features", (c) => proxyText(c, `${BASE}/skills/sentry-feature-setup/SKILL.md`));
app.get("/skills", (c) => {
const url = new URL(c.req.url);
return c.redirect(`/${url.search}`, 301);
});
app.get("/skills/*", (c) => {
const url = new URL(c.req.url);
const canonicalPath = c.req.path.slice("/skills".length) || "/";
return c.redirect(`${canonicalPath}${url.search}`, 301);
});

app.get('*', async (c) => {
let path = c.req.path;
if (path.length > 1 && path.endsWith('/')) {
path = path.slice(0, -1);
}

// Redirect /skills/... to /... (canonical URLs don't include the prefix)
if (path.startsWith('/skills/')) {
return c.redirect(path.slice('/skills'.length), 301);
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing route for /SKILL_TREE.md endpoint returns 404

High Severity

The /SKILL_TREE.md route that was previously handled by mapPath (which mapped both / and /SKILL_TREE.md to ${BASE}/SKILL_TREE.md) is now missing. The /:skill/* route won't match it because Hono's /:param/* pattern requires at least two path segments, and /SKILL_TREE.md is a single segment. Requests to /SKILL_TREE.md will now return 404 instead of the expected skill tree content. The PR test plan explicitly lists this route as needing verification.

Fix in Cursor Fix in Web

const url = mapPath(path);
app.get("/:skill/*", (c) => {
const url = buildSkillUrl(c.req.path);
if (!url) {
return c.text('Bad Request', 400);
}

const cached = getCache(url);
if (cached) {
return c.text(cached.body, cached.status as any, {
'Content-Type': 'text/plain; charset=utf-8',
});
return c.text("Bad Request", 400);
}

let body: string;
let status: number;

try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
body = await res.text();
status = res.status;
if (status >= 500) {
const stale = cache.get(url);
if (stale) {
return c.text(stale.body, stale.status as any, {
'Content-Type': 'text/plain; charset=utf-8',
});
}
}
setCache(url, body, status);
} catch (err) {
const stale = cache.get(url);
if (stale) {
return c.text(stale.body, stale.status as any, {
'Content-Type': 'text/plain; charset=utf-8',
});
}
return c.text('Bad Gateway', 502);
}

return c.text(body, status as any, {
'Content-Type': 'text/plain; charset=utf-8',
});
return proxyText(c, url);
});

const port = Number(process.env.PORT) || 3000;
serve({ fetch: app.fetch, port }, () => {
console.log(`Listening on port ${port}`);
console.log(`Listening on http://localhost:${port}`);
});