From 1c8c8617f9be2526efb3e5d4e181b6fa5353b180 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 11 Oct 2026 12:14:19 +0100 Subject: [PATCH] refactor: migrate to use `forges` --- .../runtime/server/auth/github/[slug].ts | 25 +++----- modules/runtime/server/slides/[id].pdf.ts | 37 +----------- modules/slides.ts | 23 ++----- nuxt.config.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 9 +++ pnpm-workspace.yaml | 1 + server/api/admin/github-releases.get.ts | 28 ++------- server/api/update-location.post.ts | 8 +-- server/routes/auth/[...provider].get.ts | 60 ++++++------------- server/utils/github.ts | 46 +++++++++----- server/utils/sponsors.ts | 48 ++++++++------- test/unit/bundle.spec.ts | 5 +- 13 files changed, 117 insertions(+), 176 deletions(-) diff --git a/modules/invites/runtime/server/auth/github/[slug].ts b/modules/invites/runtime/server/auth/github/[slug].ts index d375bf4d..7253b0e6 100644 --- a/modules/invites/runtime/server/auth/github/[slug].ts +++ b/modules/invites/runtime/server/auth/github/[slug].ts @@ -41,8 +41,8 @@ export default defineEventHandler(async event => { }) } - const username = await query(access_token, `{ viewer { login } }`) - .then(r => r?.viewer.login) + const username = await createGitHub(access_token).users.me() + .then(user => user.login) .catch(err => { console.error('viewer', err) return null @@ -55,27 +55,16 @@ export default defineEventHandler(async event => { }) } + const [owner = '', name = ''] = repo.split('/') try { - const res = await fetch( - `https://api.github.com/repos/${repo}/collaborators/${encodeURIComponent(username)}`, - { - method: 'PUT', - body: JSON.stringify({ permission: 'push' }), - headers: { - 'User-Agent': 'https://roe.dev', - 'Content-Type': 'application/json', - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Authorization': `Bearer ${config.github.inviteToken}`, - }, - }, + await createGitHub(config.github.inviteToken).repos.addCollaborator( + { forge: 'github', instance: 'github.com', owner, name }, + username, + 'write', ) - - console.log({ res: await res.json() }) } catch (err) { console.log('could not add collaborator', err) - console.log({ inviteToken: config.github.inviteToken }) } return sendRedirect(event, `https://github.com/${repo}/invitations`) }) diff --git a/modules/runtime/server/slides/[id].pdf.ts b/modules/runtime/server/slides/[id].pdf.ts index b13d9830..baf0b3b1 100644 --- a/modules/runtime/server/slides/[id].pdf.ts +++ b/modules/runtime/server/slides/[id].pdf.ts @@ -2,17 +2,9 @@ * Dev-only handler for `/slides/:id.pdf`. */ import { createError, defineEventHandler, getRouterParam, useRuntimeConfig } from 'nuxt/server' -import { $fetch } from 'ofetch' import { useAirspace } from '#server/utils/airspace' - -interface GitHubReleaseAsset { - id: number - name: string -} -interface GitHubRelease { - assets: GitHubReleaseAsset[] -} +import { createGitHub, downloadSlides } from '#server/utils/github' const assetCache = new Map() let knownIds: Set | null = null @@ -48,31 +40,8 @@ export default defineEventHandler(async event => { const cached = assetCache.get(id) if (cached) return new Uint8Array(cached) - const release = await $fetch( - `https://api.github.com/repos/danielroe/slides/releases/tags/${id}`, - { - headers: { - 'Authorization': `token ${config.github.token}`, - 'Accept': 'application/vnd.github+json', - 'User-Agent': 'roe.dev-dev', - }, - }, - ) - - const assetId = release.assets.find(a => a.name.endsWith('.pdf'))?.id - if (!assetId) throw createError({ status: 404 }) - - const file = await $fetch( - `https://api.github.com/repos/danielroe/slides/releases/assets/${assetId}`, - { - responseType: 'arrayBuffer', - headers: { - 'Authorization': `token ${config.github.token}`, - 'Accept': 'application/octet-stream', - 'User-Agent': 'roe.dev-dev', - }, - }, - ) + const file = await downloadSlides(createGitHub(config.github.token), id) + if (!file) throw createError({ status: 404 }) assetCache.set(id, file) return new Uint8Array(file) diff --git a/modules/slides.ts b/modules/slides.ts index e514ac34..22ee78fc 100644 --- a/modules/slides.ts +++ b/modules/slides.ts @@ -1,7 +1,7 @@ import { addServerHandler, createResolver, defineNuxtModule, useNuxt, useRuntimeConfig } from 'nuxt/kit' -import { $fetch } from 'ofetch' import { put, head } from '@vercel/blob' +import { createGitHub, downloadSlides } from '../server/utils/github.ts' import { useBuildAirspace } from './shared/airspace.ts' export default defineNuxtModule({ @@ -23,12 +23,7 @@ export default defineNuxtModule({ return } - const $gh = $fetch.create({ - baseURL: 'https://api.github.com/repos/danielroe/slides/releases', - headers: { - Authorization: `token ${config.github.token}`, - }, - }) + const gh = createGitHub(config.github.token) const token = config.blobReadWriteToken @@ -39,15 +34,9 @@ export default defineNuxtModule({ const existingBlob = await head(pathname, { token }).catch(() => null) if (!existingBlob) { - const ghRelease = await $gh(`/tags/${release}`) - const id = ghRelease?.assets.find(a => a.name.endsWith('.pdf'))?.id + const file = await downloadSlides(gh, release) - if (!id) continue - - const file = await $gh(`/assets/${id}`, { - responseType: 'arrayBuffer', - headers: { Accept: 'application/octet-stream' }, - }) + if (!file) continue // Upload file to Vercel Blob const blob = await put(pathname, Buffer.from(file), { @@ -78,7 +67,3 @@ async function fetchSlideReleases (): Promise { .map(r => r.value.slides) .filter((s): s is string => Boolean(s)) } - -interface GitHubRelease { - assets: Array<{ id: number, name: string }> -} diff --git a/nuxt.config.ts b/nuxt.config.ts index 60927c5a..cf3a8dcf 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -167,7 +167,7 @@ export default defineNuxtConfig({ }, github: { // my GH ID - id: 'MDQ6VXNlcjI4NzA2Mzcy', + id: '28706372', // fetching GH repos in build + sponsors when deployed token: '', // token used to update GitHub status diff --git a/package.json b/package.json index 98b03a13..2602704e 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "comark-content": "0.4.1", "defu": "^6.1.7", "feed": "^6.0.0", + "forges": "0.1.0", "gsap": "^3.15.0", "image-meta": "^0.3.0", "knitwork": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f1f5808..b51f5d42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,9 @@ importers: feed: specifier: ^6.0.0 version: 6.0.0 + forges: + specifier: 0.1.0 + version: 0.1.0 gsap: specifier: ^3.15.0 version: 3.15.0 @@ -4759,6 +4762,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forges@0.1.0: + resolution: {integrity: sha512-rgp+XOQDUWFY7b0tXKynrpaq/AOjyu9nt2wPtK4O2MSvU4nFhEEKV/WyNGVhSWMDL+Dn0kRmNBeP376DauU0SA==} + engines: {node: '>=22.18'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -12760,6 +12767,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forges@0.1.0: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 35237844..bdd7c9bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ minimumReleaseAgeExclude: - '@nuxt/vite-builder@4.6.1' - '@nuxt/vite-server@4.6.1' - nuxt@4.6.1 + - forges@0.1.0 overrides: # comark-content@0.4.0 still pins ^0.6.2 diff --git a/server/api/admin/github-releases.get.ts b/server/api/admin/github-releases.get.ts index a85e0b7a..4cb4dbb6 100644 --- a/server/api/admin/github-releases.get.ts +++ b/server/api/admin/github-releases.get.ts @@ -2,16 +2,9 @@ * List releases from danielroe/slides for the talk-editor slides datalist. */ import { createError, defineEventHandler, useRuntimeConfig } from 'nuxt/server' -import { $fetch } from 'ofetch' import { requireAdminSession } from '../../utils/admin/oauth' - -interface GitHubRelease { - tag_name: string - name: string | null - published_at: string | null - draft: boolean -} +import { createGitHub, SLIDES_REPO } from '../../utils/github' export default defineEventHandler(async event => { await requireAdminSession(event) @@ -25,23 +18,14 @@ export default defineEventHandler(async event => { } try { - const releases = await $fetch( - 'https://api.github.com/repos/danielroe/slides/releases?per_page=100', - { - headers: { - 'Authorization': `token ${config.github.token}`, - 'User-Agent': 'roe.dev-admin', - 'Accept': 'application/vnd.github+json', - }, - }, - ) + const releases = await Array.fromAsync(createGitHub(config.github.token).releases.list(SLIDES_REPO, { perPage: 100 })) return releases - .filter(r => !r.draft) + .filter(r => !r.isDraft) .map(r => ({ - tag: r.tag_name, - name: r.name || r.tag_name, - publishedAt: r.published_at, + tag: r.tag, + name: r.name || r.tag, + publishedAt: r.publishedAt?.toISOString() ?? null, })) } catch (err) { diff --git a/server/api/update-location.post.ts b/server/api/update-location.post.ts index 647dee49..d37ae791 100644 --- a/server/api/update-location.post.ts +++ b/server/api/update-location.post.ts @@ -59,13 +59,13 @@ export default defineEventHandler(async event => { ? String.fromCodePoint(...[...locationData.countryCode.toUpperCase()].map(char => char.charCodeAt(0) + 127397)) : '🌍' - await query(config.github.profileToken, ` - mutation { - changeUserStatus(input: { emoji: "${emoji}" }) { + await githubGraphQL(createGitHub(config.github.profileToken), ` + mutation ($emoji: String!) { + changeUserStatus(input: { emoji: $emoji }) { status { emoji } } } - `) + `, { emoji }) } catch (error) { console.error('Failed to update GitHub status:', error) diff --git a/server/routes/auth/[...provider].get.ts b/server/routes/auth/[...provider].get.ts index 8d97d992..230b18b6 100644 --- a/server/routes/auth/[...provider].get.ts +++ b/server/routes/auth/[...provider].get.ts @@ -37,60 +37,38 @@ export default defineEventHandler(async event => { }) if (access_token) { + const gh = createGitHub(access_token) // Determine if user is a sponsor - const [viewer, ids] = await Promise.all([ - query(access_token, organisationQuery) - .then(r => { - const viewer = r?.viewer || {} - viewer.orgs - = viewer.organizations.edges.map((e: any) => e.node.id) || [] - return viewer - }) + const [viewer, orgs, ids] = await Promise.all([ + gh.users.me().catch(err => { + console.error('viewer', err) + return undefined + }), + gh.request>('GET', '/user/orgs', { query: { per_page: 100 } }) + .then(r => r.data.map(org => String(org.id))) .catch(err => { - console.error('viewer', err) - return {} + console.error('orgs', err) + return [] as string[] }), getSponsors() .then(r => r.map(s => s.id)) .catch(err => { console.error('sponsor', err) - return [] + return [] as string[] }), ]) - console.info({ - sponsor: - // @ts-expect-error no typings for gh api response - ids.includes(viewer.id) || ids.some(i => viewer.orgs.includes(i)), - avatar: viewer.avatarUrl, - name: viewer.name, - }) + const session = { + sponsor: (!!viewer && ids.includes(viewer.id)) || orgs.some(id => ids.includes(id)), + avatar: viewer?.avatarUrl, + name: viewer?.name, + } + + console.info(session) // set custom JWT claim - await setUserSession(event, { - authenticated: true, - sponsor: - // @ts-expect-error no typings for gh api response - ids.includes(viewer.id) || ids.some(i => viewer.orgs.includes(i)), - avatar: viewer.avatarUrl, - name: viewer.name, - }) + await setUserSession(event, { authenticated: true, ...session }) } return sendRedirect(event, '/') }) - -const organisationQuery = `{ - viewer { - id - name - avatarUrl - organizations(first: 100) { - edges { - node { - id - } - } - } - } -}` diff --git a/server/utils/github.ts b/server/utils/github.ts index afec0240..0e7f0f8a 100644 --- a/server/utils/github.ts +++ b/server/utils/github.ts @@ -1,16 +1,32 @@ -export const query = (accessToken: string, query: string) => - fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - 'authorization': `Bearer ${accessToken}`, - 'user-agent': 'roe (https://roe.dev, 0.1)', - }, - body: JSON.stringify({ query }), +import type { ForgeProvider, RepoRef } from 'forges' +import { github } from 'forges/github' + +export const SLIDES_REPO: RepoRef = { forge: 'github', instance: 'github.com', owner: 'danielroe', name: 'slides' } + +export function createGitHub (token: string): ForgeProvider { + return github({ + auth: { type: 'token', token }, + userAgent: 'roe (https://roe.dev, 0.1)', + }).create() +} + +export async function githubGraphQL ( + gh: ForgeProvider, + query: string, + variables?: Record, +): Promise { + const { data } = await gh.request<{ data?: T, errors?: Array<{ message: string }> }>('POST', '/graphql', { + body: { query, variables }, }) - .then(r => r.json() as Promise<{ data: any }>) - .then(r => { - if ('errors' in r) { - throw r - } - return r.data - }) + if (data.errors?.length || !data.data) { + throw new Error(`GitHub GraphQL request failed: ${data.errors?.map(e => e.message).join('; ') || 'empty response'}`) + } + return data.data +} + +export async function downloadSlides (gh: ForgeProvider, tag: string): Promise { + const release = await gh.releases.getByTag(SLIDES_REPO, tag) + const asset = release.assets?.find(a => a.name.endsWith('.pdf'))?.ref + if (!asset) return + return new Response(await gh.releases.downloadAsset(asset)).arrayBuffer() +} diff --git a/server/utils/sponsors.ts b/server/utils/sponsors.ts index 6ff5cf16..cd6860cc 100644 --- a/server/utils/sponsors.ts +++ b/server/utils/sponsors.ts @@ -1,6 +1,6 @@ import { useRuntimeConfig } from 'nuxt/server' -import { query } from './github' +import { createGitHub, githubGraphQL } from './github' interface Sponsor { id: string @@ -16,10 +16,12 @@ export async function getSponsors (): Promise { if (!token) return [] if (import.meta.dev && devSponsors) return devSponsors - const sponsors: Sponsor[] = await query( - token, - sponsorQuery, - ).then(r => r?.user.sponsors.edges.map((e: any) => e.node) || []) + const data = await githubGraphQL(createGitHub(token), sponsorQuery) + const sponsors: Sponsor[] = data.user.sponsors.nodes.map(node => ({ + id: String(node.databaseId), + avatarUrl: node.avatarUrl, + name: node.name ?? undefined, + })) // my ID sponsors.push({ id: useRuntimeConfig().github.id }) @@ -27,12 +29,12 @@ export async function getSponsors (): Promise { // NuxtLabs sponsors.unshift({ name: 'Vercel', - id: 'MDEyOk9yZ2FuaXphdGlvbjE0OTg1MDIw', + id: '14985020', avatarUrl: 'https://avatars.githubusercontent.com/u/14985020?v=4', }, { name: 'NuxtLabs', - id: 'MDEyOk9yZ2FuaXphdGlvbjYyMDE3NDAw', + id: '62017400', avatarUrl: 'https://avatars.githubusercontent.com/u/62017400?v=4', }) @@ -40,22 +42,28 @@ export async function getSponsors (): Promise { return sponsors } -const sponsorQuery = /* graqhql */ ` +interface SponsorsResponse { + user: { + sponsors: { + nodes: Array<{ databaseId: number, avatarUrl: string, name: string | null }> + } + } +} + +const sponsorQuery = /* graphql */ ` { user(login: "danielroe") { sponsors(first: 100) { - edges { - node { - ... on User { - id - avatarUrl - name - } - ... on Organization { - id - avatarUrl - name - } + nodes { + ... on User { + databaseId + avatarUrl + name + } + ... on Organization { + databaseId + avatarUrl + name } } } diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index 01f8da7f..f690acd1 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -77,7 +77,7 @@ describe('project sizes', () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes, 10)) - .toMatchInlineSnapshot(`"2050k"`) + .toMatchInlineSnapshot(`"2060k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) const portableModules = await analyzeSizes( @@ -86,7 +86,7 @@ describe('project sizes', () => { ) expect .soft(roundToKilobytes(portableModules.totalBytes, 10)) - .toMatchInlineSnapshot(`"14210k"`) + .toMatchInlineSnapshot(`"14420k"`) const packages = modules.files .filter(m => m.endsWith('package.json') && !NATIVE_BINARY_RE.test(m)) @@ -150,6 +150,7 @@ describe('project sizes', () => { "events-to-async/module", "feed", "fnv1a-64", + "forges", "hookable", "image-meta", "ipaddr.js", -- 2.51.2