feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
committed by Mythie
parent 9183f668d3
commit 75d7336763
1021 changed files with 60930 additions and 40839 deletions

View File

@ -0,0 +1,100 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { PDFDocument } from 'pdf-lib';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import type { HonoEnv } from '../router';
import {
type TGetPresignedGetUrlResponse,
type TGetPresignedPostUrlResponse,
ZGetPresignedGetUrlRequestSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
export const filesRoute = new Hono<HonoEnv>()
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
const { file } = c.req.valid('form');
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Todo: (RR7) This is new.
// Add file size validation.
// Convert MB to bytes (1 MB = 1024 * 1024 bytes)
const MAX_FILE_SIZE = APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large' }, 400);
}
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
if (pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
}
// Todo: (RR7) Test this.
if (!file.name.endsWith('.pdf')) {
Object.defineProperty(file, 'name', {
writable: true,
value: `${file.name}.pdf`,
});
}
const { type, data } = await putFileServerSide(file);
const result = await createDocumentData({ type, data });
return c.json(result);
} catch (error) {
console.error('Upload failed:', error);
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-get-url', sValidator('json', ZGetPresignedGetUrlRequestSchema), async (c) => {
const { key } = await c.req.json();
try {
const { url } = await getPresignGetUrl(key || '');
return c.json({ url } satisfies TGetPresignedGetUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const { fileName, contentType } = c.req.valid('json');
try {
const { key, url } = await getPresignPostUrl(fileName, contentType);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
});

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
export const ZUploadPdfRequestSchema = z.object({
file: z.instanceof(File),
});
export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
type: true,
id: true,
});
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export const ZGetPresignedGetUrlRequestSchema = z.object({
key: z.string().min(1),
});
export const ZGetPresignedGetUrlResponseSchema = z.object({
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export type TGetPresignedGetUrlRequest = z.infer<typeof ZGetPresignedGetUrlRequestSchema>;
export type TGetPresignedGetUrlResponse = z.infer<typeof ZGetPresignedGetUrlResponseSchema>;

View File

@ -0,0 +1,67 @@
import type { Context, Next } from 'hono';
import { extractSessionCookieFromHeaders } from '@documenso/auth/server/lib/session/session-cookies';
import {
type RequestMetadata,
extractRequestMetadata,
} from '@documenso/lib/universal/extract-request-metadata';
export type AppContext = {
requestMetadata: RequestMetadata;
};
/**
* Apply a context which can be accessed throughout the app.
*
* Keep this as lean as possible in terms of awaiting, because anything
* here will increase each page load time.
*/
export const appContext = async (c: Context, next: Next) => {
const request = c.req.raw;
const url = new URL(request.url);
const noSessionCookie = extractSessionCookieFromHeaders(request.headers) === null;
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
});
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return next();
}
// Add context to any pages you want here.
return next();
};
const setAppContext = (c: Context, context: AppContext) => {
c.set('context', context);
};
const isPageRequest = (request: Request) => {
const url = new URL(request.url);
if (request.method !== 'GET') {
return false;
}
// If it ends with .data it's the loader which we need to pass context for.
if (url.pathname.endsWith('.data')) {
return true;
}
if (request.headers.get('Accept')?.includes('text/html')) {
return true;
}
return false;
};
/**
* List of paths to reject
* - Urls that start with /api
* - Urls that start with _
*/
const blacklistedPathsRegex = new RegExp('^/api/|^/__');

33
apps/remix/server/main.js Normal file
View File

@ -0,0 +1,33 @@
/**
* This is the main entry point for the server which will launch the RR7 application
* and spin up auth, api, etc.
*
* Note:
* This file will be copied to the build folder during build time.
* Running this file will not work without a build.
*/
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import handle from 'hono-react-router-adapter/node';
import server from './hono/server/router.js';
import * as build from './index.js';
server.use(
serveStatic({
root: 'build/client',
onFound: (path, c) => {
if (path.startsWith('./build/client/assets')) {
// Hard cache assets with hashed file names.
c.header('Cache-Control', 'public, immutable, max-age=31536000');
} else {
// Cache with revalidation for rest of static files.
c.header('Cache-Control', 'public, max-age=0, stale-while-revalidate=86400');
}
},
}),
);
const handler = handle(build, server);
serve({ fetch: handler.fetch, port: 3000 });

View File

@ -0,0 +1,75 @@
import type { Context, Next } from 'hono';
import { deleteCookie, setCookie } from 'hono/cookie';
import { AppDebugger } from '@documenso/lib/utils/debugger';
const debug = new AppDebugger('Middleware');
/**
* Middleware for initial page loads.
*
* You won't be able to easily handle sequential page loads because they will be
* called under `path.data`
*
* Example an initial page load would be `/documents` then if the user click templates
* the path here would be `/templates.data`.
*/
export const appMiddleware = async (c: Context, next: Next) => {
const { req } = c;
const { path } = req;
// Paths to ignore.
if (nonPagePathRegex.test(path)) {
return next();
}
// PRE-HANDLER CODE: Place code here to execute BEFORE the route handler runs.
await next();
// POST-HANDLER CODE: Place code here to execute AFTER the route handler completes.
// This is useful for:
// - Setting cookies
// - Any operations that should happen after all route handlers but before sending the response
debug.log('Path', path);
const pathname = path.replace('.data', '');
const referrer = c.req.header('referer');
const referrerUrl = referrer ? new URL(referrer) : null;
const referrerPathname = referrerUrl ? referrerUrl.pathname : null;
// Whether to reset the preferred team url cookie if the user accesses a non team page from a team page.
const resetPreferredTeamUrl =
referrerPathname &&
referrerPathname.startsWith('/t/') &&
(!pathname.startsWith('/t/') || pathname === '/');
// Set the preferred team url cookie if user accesses a team page.
if (pathname.startsWith('/t/')) {
debug.log('Setting preferred team url cookie');
setCookie(c, 'preferred-team-url', pathname.split('/')[2], {
sameSite: 'lax',
});
return;
}
// Clear preferred team url cookie if user accesses a non team page from a team page.
if (resetPreferredTeamUrl || pathname === '/documents') {
debug.log('Deleting preferred team url cookie');
deleteCookie(c, 'preferred-team-url');
return;
}
};
// This regex matches any path that:
// 1. Starts with /api/, /ingest/, /__manifest/, or /assets/
// 2. Starts with /apple- (like /apple-touch-icon.png)
// 3. Starts with /favicon (like /favicon.ico)
// The ^ ensures matching from the beginning of the string
// The | acts as OR operator between different patterns
const nonPagePathRegex = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/apple-.*|\/favicon.*)/;

View File

@ -0,0 +1,50 @@
import { Hono } from 'hono';
import { contextStorage } from 'hono/context-storage';
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { openApiDocument } from '@documenso/trpc/server/open-api';
import { filesRoute } from './api/files';
import { type AppContext, appContext } from './context';
import { appMiddleware } from './middleware';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
export interface HonoEnv {
Variables: {
context: AppContext;
};
}
const app = new Hono<HonoEnv>();
/**
* Attach session and context to requests.
*/
app.use(contextStorage());
app.use(appContext);
/**
* RR7 app middleware.
*/
app.use('*', appMiddleware);
// Auth server.
app.route('/api/auth', auth);
// Files route.
app.route('/api/files', filesRoute);
// API servers.
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
export default app;

View File

@ -0,0 +1,32 @@
import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => {
return createOpenApiFetchHandler<typeof appRouter>({
endpoint: API_V2_BETA_URL,
router: appRouter,
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
req: c.req.raw,
onError: (opts) => handleTrpcRouterError(opts, 'apiV2'),
// Not sure why we need to do this since we handle it in errorFormatter which runs after this.
responseMeta: (opts) => {
if (opts.errors[0]?.cause instanceof AppError) {
const appError = AppError.parseError(opts.errors[0].cause);
const httpStatus = genericErrorCodeToTrpcErrorCodeMap[appError.code]?.status ?? 400;
return {
status: httpStatus,
};
}
return {};
},
});
};

View File

@ -0,0 +1,15 @@
import { trpcServer } from '@hono/trpc-server';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
/**
* Trpc server for internal routes like /api/trpc/*
*/
export const reactRouterTrpcServer = trpcServer({
router: appRouter,
endpoint: '/api/trpc',
createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }),
onError: (opts) => handleTrpcRouterError(opts, 'trpc'),
});

View File

@ -0,0 +1,13 @@
import { getContext } from 'hono/context-storage';
import type { AppContext } from 'server/context';
import type { HonoEnv } from 'server/router';
/**
* Get the full context passed to the loader.
*
* @returns The full app context.
*/
export const getOptionalLoaderContext = (): AppContext => {
const { context } = getContext<HonoEnv>().var;
return context;
};