Skip to content
Closed
Show file tree
Hide file tree
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
104 changes: 104 additions & 0 deletions .github/workflows/frontend-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Frontend Docker

on:
push:
branches:
- main
- prod
paths:
- 'apps/frontend/**/*'
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- 'packages/api-client/**/*'
- 'packages/blog/**/*'
- '**/pnpm-*.yaml'
- .github/workflows/frontend-docker.yml
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}

jobs:
build:
name: Build frontend
runs-on: namespace-profile-modrinth-frontend
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Configure environment
id: meta
env:
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
else
echo "env=production" >> $GITHUB_OUTPUT
fi

- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc

- name: Enable Corepack
run: corepack enable

- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
pnpm

- name: Inject build variables
working-directory: ./apps/frontend
run: |
if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
echo "Injecting staging variables from wrangler.jsonc..."
jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
else
echo "Injecting production variables from wrangler.jsonc..."
jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
fi

- name: Install dependencies
working-directory: ./apps/frontend
run: pnpm install

# nuxi is called directly because `pnpm build` hardcodes NODE_OPTIONS, which would
# drop the semi-space bump that cuts roughly 5% off the build.
- name: Build frontend
working-directory: ./apps/frontend
run: pnpm exec nuxi build
env:
NITRO_PRESET: node-server
NODE_OPTIONS: '--max-old-space-size=8192 --max-semi-space-size=64'
BUILD_ENV: ${{ steps.meta.outputs.env }}
CF_PAGES_BRANCH: ${{ github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ github.sha }}

- name: Stage Docker context
run: |
mkdir -p apps/frontend/docker-stage
cp -r apps/frontend/.output apps/frontend/docker-stage/.output

- name: Upload Docker context
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
name: frontend-docker-context
retention-days: 1
path: apps/frontend/docker-stage

docker-build:
needs: [build]
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
with:
image-name: frontend
dockerfile-path: apps/frontend/Dockerfile
artifacts-name: frontend-docker-context
30 changes: 30 additions & 0 deletions apps/frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1

FROM node:24-slim

LABEL org.opencontainers.image.source=https://github.com/modrinth/code
LABEL org.opencontainers.image.title=frontend
LABEL org.opencontainers.image.description="Modrinth website"
LABEL org.opencontainers.image.licenses=AGPL-3.0-only

RUN apt-get update \
&& apt-get install -y --no-install-recommends dumb-init \
&& rm -rf /var/lib/apt/lists/*

ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000

WORKDIR /app

# Nitro bundles every runtime dependency into .output, so no node_modules is needed.
COPY --chown=node:node .output ./.output

USER node
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/robots.txt').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "/app/.output/server/index.mjs"]
2 changes: 2 additions & 0 deletions apps/frontend/Dockerfile.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.output
20 changes: 1 addition & 19 deletions apps/frontend/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,23 +122,6 @@ export default defineNuxtConfig({
},
},
hooks: {
async 'nitro:config'(nitroConfig) {
const emailTemplates = Object.keys(
await import('./src/templates/emails/index.ts').then((m) => m.default),
)
const docTemplates = Object.keys(
await import('./src/templates/docs/index.ts').then((m) => m.default),
)

nitroConfig.prerender = nitroConfig.prerender || {}
nitroConfig.prerender.routes = nitroConfig.prerender.routes || []
for (const template of emailTemplates) {
nitroConfig.prerender.routes.push(`/_internal/templates/email/${template}`)
}
for (const template of docTemplates) {
nitroConfig.prerender.routes.push(`/_internal/templates/doc/${template}`)
}
},
async 'build:before'() {
// 30 minutes
const TTL = 30 * 60 * 1000
Expand Down Expand Up @@ -290,6 +273,7 @@ export default defineNuxtConfig({
external: ['cloudflare:workers'],
},
preset: 'cloudflare_module',
noExternals: true,
cloudflare: {
nodeCompat: true,
},
Expand Down Expand Up @@ -325,14 +309,12 @@ export default defineNuxtConfig({
redirect: '/_internal/templates/email/**',
},
'/_internal/templates/email/**': {
prerender: true,
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=3600',
},
},
'/_internal/templates/doc/**': {
prerender: true,
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=3600',
Expand Down
19 changes: 10 additions & 9 deletions apps/frontend/src/composables/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
* This composable is kept for legacy code that hasn't been migrated yet.
*/

import { useVisitorUserAgent } from '~/composables/visitor-user-agent.ts'
import { withLabrinthCanaryHeader } from '~/helpers/canary.ts'
import { readEnv } from '~/helpers/env.ts'
import { getFrontendUserAgent, VISITOR_USER_AGENT_HEADER } from '~/helpers/user-agent.ts'

let cachedRateLimitKey = undefined
let rateLimitKeyPromise = undefined
Expand All @@ -13,15 +16,7 @@ async function getRateLimitKey(config) {
if (cachedRateLimitKey !== undefined) return cachedRateLimitKey

if (!rateLimitKeyPromise) {
rateLimitKeyPromise = (async () => {
try {
const mod = 'cloudflare:workers'
const { env } = await import(/* @vite-ignore */ mod)
return await env.RATE_LIMIT_IGNORE_KEY?.get()
} catch {
return undefined
}
})()
rateLimitKeyPromise = readEnv('RATE_LIMIT_IGNORE_KEY')
}

cachedRateLimitKey = await rateLimitKeyPromise
Expand All @@ -40,6 +35,12 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {

if (import.meta.server) {
options.headers['x-ratelimit-key'] = await getRateLimitKey(config)
options.headers['User-Agent'] = getFrontendUserAgent(config.public.hash)

const visitorUserAgent = useVisitorUserAgent()
if (visitorUserAgent) {
options.headers[VISITOR_USER_AGENT_HEADER] = visitorUserAgent
}
}

if (!skipAuth) {
Expand Down
9 changes: 9 additions & 0 deletions apps/frontend/src/composables/visitor-user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { tryUseNuxtApp } from '#imports'

// Nuxt's `useRequestHeaders` throws outside of a setup context, and `useBaseFetch` is
// called from plenty of places that no longer have one.
export function useVisitorUserAgent(): string | undefined {
if (!import.meta.server) return undefined

return tryUseNuxtApp()?.ssrContext?.event?.node?.req?.headers['user-agent']
}
20 changes: 8 additions & 12 deletions apps/frontend/src/helpers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,23 @@ import {
import type { Ref } from 'vue'

import { useFeatureFlags } from '~/composables/featureFlags.ts'
import { useVisitorUserAgent } from '~/composables/visitor-user-agent.ts'
import { withStagingArchonBaseUrl } from '~/helpers/archon.ts'

async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
try {
const mod = 'cloudflare:workers'
const { env } = await import(/* @vite-ignore */ mod)
return await env.RATE_LIMIT_IGNORE_KEY?.get()
} catch {
// Not running in Cloudflare Workers environment
return undefined
}
}
import { readEnv } from '~/helpers/env.ts'
import { getFrontendUserAgent, VISITOR_USER_AGENT_HEADER } from '~/helpers/user-agent.ts'

export function createModrinthClient(
auth: Ref<{ token: string | undefined }>,
config: {
apiBaseUrl: string
archonBaseUrl: string
sharedInstancesBaseUrl: string
commitHash: string
rateLimitKey?: string
},
): NuxtModrinthClient {
const flags = useFeatureFlags()
const visitorUserAgent = useVisitorUserAgent()
const optionalFeatures = [
import.meta.dev ? (new VerboseLoggingFeature() as AbstractFeature) : undefined,
].filter(Boolean) as AbstractFeature[]
Expand All @@ -46,8 +40,10 @@ export function createModrinthClient(
archonBaseUrl: () =>
withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging),
sharedInstancesBaseUrl: config.sharedInstancesBaseUrl,
userAgent: () => (import.meta.server ? getFrontendUserAgent(config.commitHash) : undefined),
headers: visitorUserAgent ? { [VISITOR_USER_AGENT_HEADER]: visitorUserAgent } : undefined,
archonSentryCapture: () => flags.value.archonSentryCapture,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
rateLimitKey: config.rateLimitKey || (() => readEnv('RATE_LIMIT_IGNORE_KEY')),
features: [
// for modrinth hosting
// is skipped for normal reqs
Expand Down
22 changes: 22 additions & 0 deletions apps/frontend/src/helpers/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
type SecretsStoreBinding = { get: () => Promise<string> }

// Cloudflare exposes plain vars and Secrets Store bindings on the Workers env. Every
// other runtime (the Docker image, local dev) only has process.env.
async function getWorkersEnv(): Promise<Record<string, unknown> | undefined> {
try {
const mod = 'cloudflare:workers'
const { env } = await import(/* @vite-ignore */ mod)
return env as Record<string, unknown>
} catch {
return undefined
}
}

export async function readEnv(name: string): Promise<string | undefined> {
const binding = (await getWorkersEnv())?.[name]

if (typeof binding === 'string') return binding
if (binding) return await (binding as SecretsStoreBinding).get()

return globalThis.process?.env?.[name]
}
5 changes: 5 additions & 0 deletions apps/frontend/src/helpers/user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const VISITOR_USER_AGENT_HEADER = 'X-Forwarded-User-Agent'

export function getFrontendUserAgent(commitHash: string): string {
return `modrinth/frontend/${commitHash || 'unknown'} (support@modrinth.com)`
}
2 changes: 1 addition & 1 deletion apps/frontend/src/pages/hosting/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
src="https://cdn.modrinth.com/servers/panel-right-dark.webp"
alt=""
aria-hidden="true"
class="pointer-events-none h-full w-fit select-none"
class="pointer-events-none h-full w-auto select-none"
/>
</div>

Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/providers/setup/modrinth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function setupModrinthClientProvider(auth: Awaited<ReturnType<typeof useA
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
commitHash: config.public.hash,
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
Expand Down
32 changes: 16 additions & 16 deletions apps/frontend/src/server/middleware/site-url.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { readEnv } from '~/helpers/env'

export default defineEventHandler(async (event) => {
try {
const mod = 'cloudflare:workers'
const { env } = await import(/* @vite-ignore */ mod)
const cfEnv = env as any
const config = useRuntimeConfig(event)
const config = useRuntimeConfig(event)

const siteUrl = await readEnv('CF_PAGES_URL')
const browserBaseUrl = await readEnv('BROWSER_BASE_URL')
const baseUrl = await readEnv('BASE_URL')
const pyroBaseUrl = await readEnv('PYRO_BASE_URL')
const stripePublishableKey = await readEnv('STRIPE_PUBLISHABLE_KEY')

if (cfEnv.CF_PAGES_URL) config.public.siteUrl = cfEnv.CF_PAGES_URL
if (cfEnv.BROWSER_BASE_URL) config.public.apiBaseUrl = cfEnv.BROWSER_BASE_URL
if (cfEnv.BASE_URL) config.apiBaseUrl = cfEnv.BASE_URL
if (cfEnv.PYRO_BASE_URL) {
config.public.pyroBaseUrl = cfEnv.PYRO_BASE_URL
config.pyroBaseUrl = cfEnv.PYRO_BASE_URL
}
if (cfEnv.STRIPE_PUBLISHABLE_KEY)
config.public.stripePublishableKey = cfEnv.STRIPE_PUBLISHABLE_KEY
} catch {
/* empty */
if (siteUrl) config.public.siteUrl = siteUrl
if (browserBaseUrl) config.public.apiBaseUrl = browserBaseUrl
if (baseUrl) config.apiBaseUrl = baseUrl
if (pyroBaseUrl) {
config.public.pyroBaseUrl = pyroBaseUrl
config.pyroBaseUrl = pyroBaseUrl
}
if (stripePublishableKey) config.public.stripePublishableKey = stripePublishableKey
})
Loading
Loading