xref: /expo/packages/@expo/server/src/index.ts (revision c9db3de7)
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