Use forms with or without client-side JavaScript--no code duplication required!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

252 lines
6.0 KiB

  1. import {
  2. GetServerSideProps,
  3. NextApiHandler,
  4. NextApiRequest as DefaultNextApiRequest,
  5. NextApiResponse as DefaultNextApiResponse, PageConfig,
  6. } from 'next';
  7. import * as nookies from 'nookies';
  8. import {IncomingMessage} from 'http';
  9. import busboy from 'busboy';
  10. import {NextApiResponse, NextApiRequest} from './common';
  11. let BODY_COOKIE_KEY : string;
  12. let STATUS_CODE_COOKIE_KEY: string;
  13. let STATUS_MESSAGE_COOKIE_KEY: string;
  14. const generateBodyCookieKey = () => {
  15. BODY_COOKIE_KEY = `ifb${Date.now()}`;
  16. };
  17. const generateStatusCodeCookieKey = () => {
  18. STATUS_CODE_COOKIE_KEY = `ifsc${Date.now()}`;
  19. };
  20. const generateStatusMessageCookieKey = () => {
  21. STATUS_MESSAGE_COOKIE_KEY = `ifsm${Date.now()}`;
  22. };
  23. const getBody = (req: IncomingMessage) => new Promise<Buffer>((resolve, reject) => {
  24. let body = Buffer.from('');
  25. req.on('data', (chunk) => {
  26. body = Buffer.concat([body, chunk]);
  27. });
  28. req.on('error', (err) => {
  29. reject(err);
  30. });
  31. req.on('end', () => {
  32. resolve(body);
  33. });
  34. });
  35. export namespace destination {
  36. export const getServerSideProps = (gspFn?: GetServerSideProps): GetServerSideProps => async (ctx) => {
  37. const req: NextApiRequest = {
  38. query: ctx.query,
  39. };
  40. const { method = 'GET' } = ctx.req;
  41. if (!['GET', 'HEAD'].includes(method.toUpperCase())) {
  42. const body = await getBody(ctx.req);
  43. req.body = body.toString('utf-8');
  44. }
  45. const cookies = nookies.parseCookies(ctx);
  46. const res: NextApiResponse = {};
  47. if (STATUS_CODE_COOKIE_KEY in cookies) {
  48. ctx.res.statusCode = Number(cookies[STATUS_CODE_COOKIE_KEY] || '200');
  49. nookies.destroyCookie(ctx, STATUS_CODE_COOKIE_KEY, {
  50. path: '/',
  51. });
  52. }
  53. if (STATUS_MESSAGE_COOKIE_KEY in cookies) {
  54. ctx.res.statusMessage = cookies[STATUS_MESSAGE_COOKIE_KEY] || '';
  55. nookies.destroyCookie(ctx, STATUS_MESSAGE_COOKIE_KEY, {
  56. path: '/',
  57. });
  58. }
  59. if (BODY_COOKIE_KEY in cookies) {
  60. const resBody = cookies[BODY_COOKIE_KEY];
  61. if (resBody.startsWith('json:')) {
  62. res.body = JSON.parse(resBody.slice(5));
  63. } else if (resBody.startsWith('raw:')) {
  64. res.body = resBody.slice(4);
  65. } else {
  66. console.warn('Could not parse response body, returning nothing');
  67. }
  68. nookies.destroyCookie(ctx, BODY_COOKIE_KEY, {
  69. path: '/',
  70. });
  71. }
  72. let gspResult;
  73. if (gspFn) {
  74. gspResult = await gspFn(ctx)
  75. } else {
  76. gspResult = {
  77. props: {},
  78. };
  79. }
  80. if ('props' in gspResult) {
  81. return {
  82. ...gspResult,
  83. props: {
  84. ...gspResult.props,
  85. req,
  86. res,
  87. },
  88. };
  89. }
  90. // redirect/not found will be treated as default behavior
  91. return gspResult;
  92. };
  93. }
  94. export namespace action {
  95. export const getApiConfig = (customConfig = {} as PageConfig) => ({
  96. api: {
  97. ...(customConfig.api ?? {}),
  98. bodyParser: false,
  99. },
  100. });
  101. export const wrapApiHandler = (fn: NextApiHandler): NextApiHandler => async (req, res) => {
  102. const body = await deserializeBody(req);
  103. const reqMut = req as unknown as Record<string, unknown>;
  104. reqMut.body = body;
  105. return fn(reqMut as unknown as DefaultNextApiRequest, res);
  106. };
  107. const parseMultipartFormData = async (req: IncomingMessage) => {
  108. return new Promise<Record<string, unknown>>((resolve, reject) => {
  109. const body: Record<string, unknown> = {};
  110. const bb = busboy({
  111. headers: req.headers,
  112. });
  113. bb.on('file', (name, file, info) => {
  114. const {
  115. filename,
  116. mimeType: mimetype
  117. } = info;
  118. let fileData = Buffer.from('');
  119. file.on('data', (data) => {
  120. fileData = Buffer.concat([fileData, data]);
  121. });
  122. file.on('close', () => {
  123. body[name] = new File([fileData.buffer], filename, {
  124. type: mimetype,
  125. });
  126. });
  127. });
  128. bb.on('field', (name, value) => {
  129. body[name] = value;
  130. });
  131. bb.on('close', () => {
  132. resolve(body);
  133. });
  134. bb.on('error', (error) => {
  135. reject(error);
  136. });
  137. req.pipe(bb);
  138. });
  139. };
  140. const deserializeBody = async (req: IncomingMessage) => {
  141. const contentType = req.headers['content-type'];
  142. // TODO get body encoding from headers
  143. const encoding = (req.headers['content-encoding'] ?? 'utf-8') as BufferEncoding;
  144. // should we turn off default body parsing?
  145. if (contentType === 'application/json') {
  146. const bodyRaw = await getBody(req);
  147. return JSON.parse(bodyRaw.toString(encoding));
  148. }
  149. if (contentType === 'application/x-www-form-urlencoded') {
  150. const bodyRaw = await getBody(req);
  151. return Object.fromEntries(
  152. new URLSearchParams(bodyRaw.toString(encoding)).entries()
  153. );
  154. }
  155. if (contentType?.startsWith('multipart/form-data;')) {
  156. return parseMultipartFormData(req);
  157. }
  158. const bodyRaw = await getBody(req);
  159. return bodyRaw.toString('binary');
  160. };
  161. export const getServerSideProps = (fn: NextApiHandler): GetServerSideProps => async (ctx) => {
  162. const { referer } = ctx.req.headers;
  163. const mockReq = {
  164. ...ctx.req,
  165. body: await deserializeBody(ctx.req),
  166. } as DefaultNextApiRequest;
  167. let data = null;
  168. const mockRes = {
  169. // todo handle other nextapiresponse methods (e.g. setting headers, writeHead, etc.)
  170. statusMessage: '',
  171. statusCode: 200,
  172. status(code: number) {
  173. // should we mask error status code to Bad Gateway?
  174. this.statusCode = code;
  175. return mockRes;
  176. },
  177. send: (raw: any) => {
  178. // todo: how to transfer binary response in a more compact way?
  179. data = `raw:${raw.toString('base64')}`;
  180. },
  181. json: (raw: any) => {
  182. data = `json:${JSON.stringify(raw)}`;
  183. },
  184. } as DefaultNextApiResponse;
  185. await fn(mockReq, mockRes);
  186. generateStatusCodeCookieKey();
  187. nookies.setCookie(ctx, STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString(), {
  188. maxAge: 30 * 24 * 60 * 60,
  189. path: '/',
  190. });
  191. generateStatusMessageCookieKey();
  192. nookies.setCookie(ctx, STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage, {
  193. maxAge: 30 * 24 * 60 * 60,
  194. path: '/',
  195. });
  196. if (data) {
  197. generateBodyCookieKey();
  198. nookies.setCookie(ctx, BODY_COOKIE_KEY, data, {
  199. maxAge: 30 * 24 * 60 * 60,
  200. path: '/',
  201. });
  202. }
  203. return {
  204. redirect: {
  205. destination: referer,
  206. statusCode: 307,
  207. },
  208. props: {
  209. query: ctx.query,
  210. body: data,
  211. },
  212. };
  213. };
  214. }