1import '@expo/server/install'; 2 3import { Response } from '@remix-run/node'; 4import type { ExpoRoutesManifestV1, RouteInfo } from 'expo-router/build/routes-manifest'; 5import fs from 'fs'; 6import path from 'path'; 7import { URL } from 'url'; 8 9import { ExpoRequest, ExpoResponse, ExpoURL, NON_STANDARD_SYMBOL } from './environment'; 10import { ExpoRouterServerManifestV1FunctionRoute } from './types'; 11 12const debug = require('debug')('expo:server') as typeof console.log; 13 14function getProcessedManifest(path: string): ExpoRoutesManifestV1<RegExp> { 15 // TODO: JSON Schema for validation 16 const routesManifest = JSON.parse(fs.readFileSync(path, 'utf-8')) as ExpoRoutesManifestV1; 17 18 const parsed: ExpoRoutesManifestV1<RegExp> = { 19 ...routesManifest, 20 notFoundRoutes: routesManifest.notFoundRoutes.map((value: any) => { 21 return { 22 ...value, 23 namedRegex: new RegExp(value.namedRegex), 24 }; 25 }), 26 apiRoutes: routesManifest.apiRoutes.map((value: any) => { 27 return { 28 ...value, 29 namedRegex: new RegExp(value.namedRegex), 30 }; 31 }), 32 htmlRoutes: routesManifest.htmlRoutes.map((value: any) => { 33 return { 34 ...value, 35 namedRegex: new RegExp(value.namedRegex), 36 }; 37 }), 38 }; 39 40 return parsed; 41} 42 43export function getRoutesManifest(distFolder: string) { 44 return getProcessedManifest(path.join(distFolder, '_expo/routes.json')); 45} 46 47// TODO: Reuse this for dev as well 48export function createRequestHandler( 49 distFolder: string, 50 { 51 getRoutesManifest: getInternalRoutesManifest, 52 getHtml = async (request, route) => { 53 // serve a static file 54 const filePath = path.join(distFolder, route.page + '.html'); 55 56 if (!fs.existsSync(filePath)) { 57 return null; 58 } 59 return fs.readFileSync(filePath, 'utf-8'); 60 }, 61 getApiRoute = async (route) => { 62 const filePath = path.join(distFolder, '_expo/functions', route.page + '.js'); 63 64 debug(`Handling API route: ${route.page}: ${filePath}`); 65 66 // TODO: What's the standard behavior for malformed projects? 67 if (!fs.existsSync(filePath)) { 68 return null; 69 } 70 71 return require(filePath); 72 }, 73 logApiRouteExecutionError = (error: Error) => { 74 console.error(error); 75 }, 76 }: { 77 getHtml?: ( 78 request: ExpoRequest, 79 route: RouteInfo<RegExp> 80 ) => Promise<string | ExpoResponse | null>; 81 getRoutesManifest?: (distFolder: string) => Promise<ExpoRoutesManifestV1<RegExp> | null>; 82 getApiRoute?: (route: RouteInfo<RegExp>) => Promise<any>; 83 logApiRouteExecutionError?: (error: Error) => void; 84 } = {} 85) { 86 let routesManifest: ExpoRoutesManifestV1<RegExp> | undefined; 87 88 function updateRequestWithConfig( 89 request: ExpoRequest, 90 config: ExpoRouterServerManifestV1FunctionRoute 91 ) { 92 const params: Record<string, string> = {}; 93 const url = request.url; 94 95 const expoUrl = new ExpoURL(url); 96 const match = config.namedRegex.exec(expoUrl.pathname); 97 if (match?.groups) { 98 for (const [key, value] of Object.entries(match.groups)) { 99 const namedKey = config.routeKeys[key]; 100 expoUrl.searchParams.set(namedKey, value); 101 params[namedKey] = value; 102 } 103 } 104 105 request[NON_STANDARD_SYMBOL] = { 106 url: expoUrl, 107 }; 108 return params; 109 } 110 111 return async function handler(request: ExpoRequest): Promise<Response> { 112 if (getInternalRoutesManifest) { 113 const manifest = await getInternalRoutesManifest(distFolder); 114 if (manifest) { 115 routesManifest = manifest; 116 } else { 117 // Development error when Expo Router is not setup. 118 return new ExpoResponse('No routes manifest found', { 119 status: 404, 120 headers: { 121 'Content-Type': 'text/plain', 122 }, 123 }); 124 } 125 } else if (!routesManifest) { 126 routesManifest = getRoutesManifest(distFolder); 127 } 128 129 const url = new URL(request.url, 'http://expo.dev'); 130 131 const sanitizedPathname = url.pathname; 132 133 debug('Request', sanitizedPathname); 134 135 if (request.method === 'GET' || request.method === 'HEAD') { 136 // First test static routes 137 for (const route of routesManifest.htmlRoutes) { 138 if (!route.namedRegex.test(sanitizedPathname)) { 139 continue; 140 } 141 142 // // Mutate to add the expoUrl object. 143 updateRequestWithConfig(request, route); 144 145 // serve a static file 146 const contents = await getHtml(request, route); 147 148 // TODO: What's the standard behavior for malformed projects? 149 if (!contents) { 150 return new ExpoResponse('Not found', { 151 status: 404, 152 headers: { 153 'Content-Type': 'text/plain', 154 }, 155 }); 156 } else if (contents instanceof ExpoResponse) { 157 return contents; 158 } 159 160 return new ExpoResponse(contents, { 161 status: 200, 162 headers: { 163 'Content-Type': 'text/html', 164 }, 165 }); 166 } 167 } 168 169 // Next, test API routes 170 for (const route of routesManifest.apiRoutes) { 171 if (!route.namedRegex.test(sanitizedPathname)) { 172 continue; 173 } 174 175 const func = await getApiRoute(route); 176 177 if (func instanceof ExpoResponse) { 178 return func; 179 } 180 181 const routeHandler = func[request.method]; 182 if (!routeHandler) { 183 return new ExpoResponse('Method not allowed', { 184 status: 405, 185 headers: { 186 'Content-Type': 'text/plain', 187 }, 188 }); 189 } 190 191 // Mutate to add the expoUrl object. 192 const params = updateRequestWithConfig(request, route); 193 194 try { 195 // TODO: Handle undefined 196 return (await routeHandler(request, params)) as ExpoResponse; 197 } catch (error) { 198 if (error instanceof Error) { 199 logApiRouteExecutionError(error); 200 } 201 202 return new ExpoResponse('Internal server error', { 203 status: 500, 204 headers: { 205 'Content-Type': 'text/plain', 206 }, 207 }); 208 } 209 } 210 211 // Finally, test 404 routes 212 for (const route of routesManifest.notFoundRoutes) { 213 if (!route.namedRegex.test(sanitizedPathname)) { 214 continue; 215 } 216 217 // // Mutate to add the expoUrl object. 218 updateRequestWithConfig(request, route); 219 220 // serve a static file 221 const contents = await getHtml(request, route); 222 223 // TODO: What's the standard behavior for malformed projects? 224 if (!contents) { 225 return new ExpoResponse('Not found', { 226 status: 404, 227 headers: { 228 'Content-Type': 'text/plain', 229 }, 230 }); 231 } else if (contents instanceof ExpoResponse) { 232 return contents; 233 } 234 235 return new ExpoResponse(contents, { 236 status: 404, 237 headers: { 238 'Content-Type': 'text/html', 239 }, 240 }); 241 } 242 243 // 404 244 const response = new ExpoResponse('Not found', { 245 status: 404, 246 headers: { 247 'Content-Type': 'text/plain', 248 }, 249 }); 250 return response; 251 }; 252} 253 254export { ExpoResponse, ExpoRequest }; 255