changelog api

This commit is contained in:
Shoubhit Dash 2026-01-21 23:07:46 +05:30
parent 0a8c5fa049
commit 8f787763a2
7 changed files with 325 additions and 0 deletions

33
apps/changelog-api/.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# prod
dist/
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
.wrangler
# env
.env
.env.production
.dev.vars
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

View file

@ -0,0 +1,21 @@
```txt
npm install
npm run dev
```
```txt
npm run deploy
```
[For generating/synchronizing types based on your Worker configuration run](https://developers.cloudflare.com/workers/wrangler/commands/#types):
```txt
npm run cf-typegen
```
Pass the `CloudflareBindings` as generics when instantiation `Hono`:
```ts
// src/index.ts
const app = new Hono<{ Bindings: CloudflareBindings }>()
```

View file

@ -0,0 +1,17 @@
{
"name": "@supermemory/changelog-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy --minify"
},
"dependencies": {
"hono": "^4.11.4"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250620.0",
"typescript": "^5.8.3",
"wrangler": "^4.4.0"
}
}

View file

@ -0,0 +1,83 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { parseChangelog, type ChangelogResponse } from "./parser"
const MDX_URL =
"https://raw.githubusercontent.com/supermemoryai/supermemory/main/apps/docs/changelog/developer-platform.mdx"
const CACHE_KEY = "changelog-api-v1"
const CACHE_TTL_SECONDS = 3600 // 1 hour
const app = new Hono()
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "OPTIONS"],
allowHeaders: ["Content-Type"],
}),
)
async function fetchAndParseChangelog(): Promise<ChangelogResponse> {
const response = await fetch(MDX_URL)
if (!response.ok) {
throw new Error(`Failed to fetch changelog: ${response.status}`)
}
const content = await response.text()
return parseChangelog(content)
}
async function getCachedChangelog(
cacheUrl: string,
): Promise<ChangelogResponse | null> {
const cache = caches.default
const cachedResponse = await cache.match(cacheUrl)
if (cachedResponse) {
return cachedResponse.json()
}
return null
}
async function cacheChangelog(
cacheUrl: string,
data: ChangelogResponse,
): Promise<void> {
const cache = caches.default
const response = new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${CACHE_TTL_SECONDS}`,
},
})
await cache.put(cacheUrl, response)
}
app.get("/", async (c) => {
const cacheUrl = new URL(CACHE_KEY, c.req.url).toString()
// Try to get from cache first
let changelog = await getCachedChangelog(cacheUrl)
if (!changelog) {
// Fetch fresh data
changelog = await fetchAndParseChangelog()
// Cache it (don't await to not block response)
c.executionCtx.waitUntil(cacheChangelog(cacheUrl, changelog))
}
return c.json(changelog, {
headers: {
"Cache-Control": `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=86400`,
},
})
})
// Health check endpoint
app.get("/health", (c) => {
return c.json({ status: "ok" })
})
export default app

View file

@ -0,0 +1,144 @@
export interface ChangelogItem {
title: string
description: string
}
export interface ChangelogEntry {
date: string // "2025-12-30"
dateFormatted: string // "December 30, 2025"
items: ChangelogItem[]
}
export interface ChangelogResponse {
title: string
description: string
lastUpdated: string // ISO date of most recent entry
entries: ChangelogEntry[]
total: number
}
function parseFrontmatter(content: string): {
frontmatter: Record<string, string>
body: string
} {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
if (!frontmatterMatch) {
return { frontmatter: {}, body: content }
}
const frontmatterStr = frontmatterMatch[1]
const body = frontmatterMatch[2]
const frontmatter: Record<string, string> = {}
for (const line of frontmatterStr.split("\n")) {
const match = line.match(/^(\w+):\s*"?([^"]*)"?$/)
if (match) {
frontmatter[match[1]] = match[2]
}
}
return { frontmatter, body }
}
function parseDate(dateStr: string): { date: string; dateFormatted: string } {
// Parse "December 30, 2025" format
const months: Record<string, string> = {
January: "01",
February: "02",
March: "03",
April: "04",
May: "05",
June: "06",
July: "07",
August: "08",
September: "09",
October: "10",
November: "11",
December: "12",
}
const match = dateStr.match(/^(\w+)\s+(\d+),\s+(\d+)$/)
if (!match) {
return { date: "", dateFormatted: dateStr }
}
const [, month, day, year] = match
const monthNum = months[month]
if (!monthNum) {
console.warn(`Unknown month "${month}" in date "${dateStr}", skipping entry`)
return { date: "", dateFormatted: dateStr }
}
const paddedDay = day.padStart(2, "0")
return {
date: `${year}-${monthNum}-${paddedDay}`,
dateFormatted: dateStr,
}
}
function parseItems(content: string): ChangelogItem[] {
const items: ChangelogItem[] = []
// Match lines starting with "- **Title:** Description" or "- **Title** Description"
const lines = content.split("\n")
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith("- **")) continue
// Match "- **Title:** Description" or "- **Title** Description"
const match = trimmed.match(/^-\s+\*\*([^*]+)\*\*:?\s*(.*)$/)
if (match) {
// Remove trailing colon from title if present (e.g., "MCP Context Prompt:" -> "MCP Context Prompt")
const title = match[1].trim().replace(/:$/, "")
items.push({
title,
description: match[2].trim(),
})
}
}
return items
}
export function parseChangelog(content: string): ChangelogResponse {
const { frontmatter, body } = parseFrontmatter(content)
const entries: ChangelogEntry[] = []
// Split by date headers (## Month Day, Year)
const sections = body.split(/^##\s+/m).filter((s) => s.trim())
for (const section of sections) {
const lines = section.split("\n")
const dateStr = lines[0].trim()
// Skip if not a valid date header
if (!dateStr.match(/^\w+\s+\d+,\s+\d+$/)) continue
const { date, dateFormatted } = parseDate(dateStr)
const items = parseItems(section)
if (items.length > 0 && date) {
entries.push({
date,
dateFormatted,
items,
})
}
}
// Sort entries by date descending (most recent first)
entries.sort((a, b) => b.date.localeCompare(a.date))
const totalItems = entries.reduce((sum, entry) => sum + entry.items.length, 0)
const lastUpdated = entries[0]?.date || ""
return {
title: frontmatter.title || "Changelog",
description: frontmatter.description || "",
lastUpdated,
entries,
total: totalItems,
}
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": "."
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,9 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "changelog-api",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"observability": {
"enabled": true
}
}