1import type { HandlerEvent, HandlerResponse } from '@netlify/functions'; 2import { Headers, readableStreamToString, RequestInit } from '@remix-run/node'; 3import { AbortController } from 'abort-controller'; 4 5import { createRequestHandler as createExpoHandler } from '..'; 6import { ExpoRequest, ExpoResponse } from '../environment'; 7 8export function createRequestHandler({ build }: { build: string }) { 9 const handleRequest = createExpoHandler(build); 10 11 return async (event: HandlerEvent) => { 12 const response = await handleRequest(convertRequest(event)); 13 14 return respond(response); 15 }; 16} 17 18export async function respond(res: ExpoResponse): Promise<HandlerResponse> { 19 const contentType = res.headers.get('Content-Type'); 20 let body: string | undefined; 21 const isBase64Encoded = isBinaryType(contentType); 22 23 if (res.body) { 24 if (isBase64Encoded) { 25 body = await readableStreamToString(res.body, 'base64'); 26 } else { 27 body = await res.text(); 28 } 29 } 30 31 const multiValueHeaders = res.headers.raw(); 32 33 return { 34 statusCode: res.status, 35 multiValueHeaders, 36 body, 37 isBase64Encoded, 38 }; 39} 40 41export function createHeaders(requestHeaders: HandlerEvent['multiValueHeaders']): Headers { 42 const headers = new Headers(); 43 44 for (const [key, values] of Object.entries(requestHeaders)) { 45 if (values) { 46 for (const value of values) { 47 headers.append(key, value); 48 } 49 } 50 } 51 52 return headers; 53} 54 55// `netlify dev` doesn't return the full url in the event.rawUrl, so we need to create it ourselves 56function getRawPath(event: HandlerEvent): string { 57 let rawPath = event.path; 58 const searchParams = new URLSearchParams(); 59 60 if (!event.multiValueQueryStringParameters) { 61 return rawPath; 62 } 63 64 const paramKeys = Object.keys(event.multiValueQueryStringParameters); 65 for (const key of paramKeys) { 66 const values = event.multiValueQueryStringParameters[key]; 67 if (!values) continue; 68 for (const val of values) { 69 searchParams.append(key, val); 70 } 71 } 72 73 const rawParams = searchParams.toString(); 74 75 if (rawParams) rawPath += `?${rawParams}`; 76 77 return rawPath; 78} 79 80export function convertRequest(event: HandlerEvent): ExpoRequest { 81 let url: URL; 82 83 if (process.env.NODE_ENV !== 'development') { 84 url = new URL(event.rawUrl); 85 } else { 86 const origin = event.headers.host; 87 const rawPath = getRawPath(event); 88 url = new URL(`http://${origin}${rawPath}`); 89 } 90 91 // Note: No current way to abort these for Netlify, but our router expects 92 // requests to contain a signal so it can detect aborted requests 93 const controller = new AbortController(); 94 95 const init: RequestInit = { 96 method: event.httpMethod, 97 headers: createHeaders(event.multiValueHeaders), 98 // Cast until reason/throwIfAborted added 99 // https://github.com/mysticatea/abort-controller/issues/36 100 signal: controller.signal as RequestInit['signal'], 101 }; 102 103 if (event.httpMethod !== 'GET' && event.httpMethod !== 'HEAD' && event.body) { 104 const isFormData = event.headers['content-type']?.includes('multipart/form-data'); 105 init.body = event.isBase64Encoded 106 ? isFormData 107 ? Buffer.from(event.body, 'base64') 108 : Buffer.from(event.body, 'base64').toString() 109 : event.body; 110 } 111 112 return new ExpoRequest(url.href, init); 113} 114 115/** 116 * Common binary MIME types 117 * @see https://github.com/architect/functions/blob/45254fc1936a1794c185aac07e9889b241a2e5c6/src/http/helpers/binary-types.js 118 */ 119const binaryTypes = [ 120 'application/octet-stream', 121 // Docs 122 'application/epub+zip', 123 'application/msword', 124 'application/pdf', 125 'application/rtf', 126 'application/vnd.amazon.ebook', 127 'application/vnd.ms-excel', 128 'application/vnd.ms-powerpoint', 129 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 130 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 131 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 132 // Fonts 133 'font/otf', 134 'font/woff', 135 'font/woff2', 136 // Images 137 'image/avif', 138 'image/bmp', 139 'image/gif', 140 'image/jpeg', 141 'image/png', 142 'image/tiff', 143 'image/vnd.microsoft.icon', 144 'image/webp', 145 // Audio 146 'audio/3gpp', 147 'audio/aac', 148 'audio/basic', 149 'audio/mpeg', 150 'audio/ogg', 151 'audio/wav', 152 'audio/webm', 153 'audio/x-aiff', 154 'audio/x-midi', 155 'audio/x-wav', 156 // Video 157 'video/3gpp', 158 'video/mp2t', 159 'video/mpeg', 160 'video/ogg', 161 'video/quicktime', 162 'video/webm', 163 'video/x-msvideo', 164 // Archives 165 'application/java-archive', 166 'application/vnd.apple.installer+xml', 167 'application/x-7z-compressed', 168 'application/x-apple-diskimage', 169 'application/x-bzip', 170 'application/x-bzip2', 171 'application/x-gzip', 172 'application/x-java-archive', 173 'application/x-rar-compressed', 174 'application/x-tar', 175 'application/x-zip', 176 'application/zip', 177]; 178 179export function isBinaryType(contentType: string | null | undefined) { 180 if (!contentType) return false; 181 const [test] = contentType.split(';'); 182 return binaryTypes.includes(test); 183} 184