From 24189479689d142364a891e27e0fa2268a15661a Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sat, 16 Sep 2023 23:03:55 +0800 Subject: [PATCH] Refactor code Group related code in backend. --- README.md | 9 +- .../iceform-next-sandbox/src/pages/a/greet.ts | 2 +- .../src/pages/api/greet.ts | 2 +- packages/iceform-next/src/client.tsx | 70 ++---- packages/iceform-next/src/server.ts | 219 +++++------------- packages/iceform-next/src/utils/body.ts | 60 +++++ packages/iceform-next/src/utils/cookies.ts | 55 +++++ .../iceform-next/src/utils/serialization.ts | 89 +++++++ 8 files changed, 285 insertions(+), 221 deletions(-) create mode 100644 packages/iceform-next/src/utils/body.ts create mode 100644 packages/iceform-next/src/utils/cookies.ts create mode 100644 packages/iceform-next/src/utils/serialization.ts diff --git a/README.md b/README.md index 36b6cb9..cb6504f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Then define the API route: import * as Iceform from '@modal-sh/iceform-next'; import { greet } from '@/handlers/greet'; -const handler = Iceform.action.wrapApiHandler(greet); +const handler = Iceform.action.wrapApiHandler({ fn: greet }); // you can extend the route config by passing an extra argument export const config = Iceform.action.getApiConfig(); @@ -66,11 +66,11 @@ const ActionGreetPage: NextPage = () => null; export default ActionGreetPage; -export const getServerSideProps = Iceform.action.getServerSideProps(greet); +export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet }); ``` Lastly, define the form page: -```ts +```tsx // [src/]pages/form.tsx import * as React from 'react'; @@ -125,4 +125,7 @@ In theory, any API route may have a corresponding action route. - [X] Content negotiation (custom request data) - [ ] Tests + - [ ] Form with redirects + - [ ] Form with files +- [ ] Documentation - [ ] Remix support diff --git a/packages/iceform-next-sandbox/src/pages/a/greet.ts b/packages/iceform-next-sandbox/src/pages/a/greet.ts index 1b7ebb2..544394b 100644 --- a/packages/iceform-next-sandbox/src/pages/a/greet.ts +++ b/packages/iceform-next-sandbox/src/pages/a/greet.ts @@ -6,4 +6,4 @@ const ActionGreetPage: NextPage = () => null; export default ActionGreetPage; -export const getServerSideProps = Iceform.action.getServerSideProps(greet); +export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet }); diff --git a/packages/iceform-next-sandbox/src/pages/api/greet.ts b/packages/iceform-next-sandbox/src/pages/api/greet.ts index 6e768e1..6c09d1c 100644 --- a/packages/iceform-next-sandbox/src/pages/api/greet.ts +++ b/packages/iceform-next-sandbox/src/pages/api/greet.ts @@ -1,7 +1,7 @@ import * as Iceform from '@modal-sh/iceform-next'; import { greet } from '@/handlers/greet'; -const handler = Iceform.action.wrapApiHandler(greet); +const handler = Iceform.action.wrapApiHandler({ fn: greet }); export const config = Iceform.action.getApiConfig(); diff --git a/packages/iceform-next/src/client.tsx b/packages/iceform-next/src/client.tsx index 52fd925..0e7695c 100644 --- a/packages/iceform-next/src/client.tsx +++ b/packages/iceform-next/src/client.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { getFormValues } from '@theoryofnekomata/formxtra'; import fetchPonyfill from 'fetch-ponyfill'; import { NextPage as DefaultNextPage } from 'next'; import { @@ -7,35 +6,41 @@ import { NextApiRequest, ENCTYPE_APPLICATION_JSON, ENCTYPE_MULTIPART_FORM_DATA, - ENCTYPE_X_WWW_FORM_URLENCODED, } from './common'; +import { + DEFAULT_ENCTYPE_SERIALIZERS, + EncTypeSerializerMap, serializeBody, + SerializerOptions, +} from './utils/serialization'; const FormDerivedElementComponent = 'form' as const; type FormDerivedElement = HTMLElementTagNameMap[typeof FormDerivedElementComponent]; -type BaseProps = React.HTMLProps; - -type EncTypeSerializer = (data: unknown) => string; +const ALLOWED_SERVER_METHODS = ['get', 'post'] as const; -type EncTypeSerializerMap = Record; +type AllowedServerMethod = typeof ALLOWED_SERVER_METHODS[number]; -const DEFAULT_ENCTYPE_SERIALIZERS: EncTypeSerializerMap = { - [ENCTYPE_APPLICATION_JSON]: (data: unknown) => JSON.stringify(data), -}; +const ALLOWED_CLIENT_METHODS = [ + ...ALLOWED_SERVER_METHODS, + 'put', + 'patch', + 'delete', +] as const; -type GetFormValuesOptions = Parameters[1]; +type AllowedClientMethod = typeof ALLOWED_CLIENT_METHODS[number]; -export interface FormProps extends Omit { +export interface FormProps extends Omit, 'action' | 'method'> { action?: string; + method?: AllowedServerMethod; clientAction?: string; clientHeaders?: HeadersInit; - clientMethod?: BaseProps['method']; + clientMethod?: AllowedClientMethod; invalidate?: (...args: unknown[]) => unknown; refresh?: (response: Response) => void; encTypeSerializers?: EncTypeSerializerMap; responseEncType?: string; - serializerOptions?: GetFormValuesOptions; + serializerOptions?: SerializerOptions; } export const useResponse = (res: NextApiResponse) => { @@ -62,40 +67,6 @@ export const useResponse = (res: NextApiResponse) => { ]); }; -interface SerializeBodyParams { - form: HTMLFormElement, - encType: string, - serializers: EncTypeSerializerMap, - options?: GetFormValuesOptions, -} - -const serializeBody = (params: SerializeBodyParams) => { - const { - form, - encType, - serializers, - options, - } = params; - - if (encType === ENCTYPE_MULTIPART_FORM_DATA) { - // type error when provided a submitter element for some reason... - const FormDataUnknown = FormData as unknown as { - new(formElement?: HTMLElement, submitter?: HTMLElement): BodyInit; - }; - return new FormDataUnknown(form, options?.submitter); - } - - if (encType === ENCTYPE_X_WWW_FORM_URLENCODED) { - return new URLSearchParams(form); - } - - if (typeof serializers[encType] === 'function') { - return serializers[encType](getFormValues(form, options)); - } - - throw new Error(`Unsupported encType: ${encType}`); -}; - export const Form = React.forwardRef(({ children, onSubmit, @@ -151,10 +122,6 @@ export const Form = React.forwardRef(({ onSubmit?.(event); }; - // TODO how to display put/patch method in form? HTML only supports get/post - // > throw error when not get/post - // TODO handle "dialog" method as invalid - const serverMethodRaw = method.toLowerCase(); const serverMethod = serverMethodRaw === 'get' ? 'get' : 'post'; @@ -181,6 +148,7 @@ Form.defaultProps = { clientMethod: undefined, encTypeSerializers: DEFAULT_ENCTYPE_SERIALIZERS, invalidate: undefined, + method: 'get' as const, refresh: undefined, responseEncType: ENCTYPE_APPLICATION_JSON, serializerOptions: undefined, diff --git a/packages/iceform-next/src/server.ts b/packages/iceform-next/src/server.ts index db52b66..445d32a 100644 --- a/packages/iceform-next/src/server.ts +++ b/packages/iceform-next/src/server.ts @@ -5,54 +5,22 @@ import { NextApiResponse as DefaultNextApiResponse, PageConfig, } from 'next'; -import * as nookies from 'nookies'; -import { IncomingMessage } from 'http'; -import busboy from 'busboy'; import { deserialize, serialize } from 'seroval'; import { NextApiResponse, NextApiRequest, ENCTYPE_APPLICATION_JSON, - ENCTYPE_X_WWW_FORM_URLENCODED, - ENCTYPE_MULTIPART_FORM_DATA, ENCTYPE_APPLICATION_OCTET_STREAM, + ENCTYPE_APPLICATION_OCTET_STREAM, } from './common'; - -let BODY_COOKIE_KEY: string; -let STATUS_CODE_COOKIE_KEY: string; -let STATUS_MESSAGE_COOKIE_KEY: string; -let CONTENT_TYPE_COOKIE_KEY: string; - -const generateContentTypeCookieKey = () => { - CONTENT_TYPE_COOKIE_KEY = `ifct${Date.now()}`; -}; - -const generateBodyCookieKey = () => { - BODY_COOKIE_KEY = `ifb${Date.now()}`; -}; - -const generateStatusCodeCookieKey = () => { - STATUS_CODE_COOKIE_KEY = `ifsc${Date.now()}`; -}; - -const generateStatusMessageCookieKey = () => { - STATUS_MESSAGE_COOKIE_KEY = `ifsm${Date.now()}`; -}; - -const getBody = (req: IncomingMessage) => new Promise((resolve, reject) => { - let body = Buffer.from(''); - - req.on('data', (chunk) => { - body = Buffer.concat([body, chunk]); - }); - - req.on('error', (err) => { - reject(err); - }); - - req.on('end', () => { - resolve(body); - }); -}); +import { getBody } from './utils/body'; +import { + CookieManager, + BODY_COOKIE_KEY, + STATUS_CODE_COOKIE_KEY, + STATUS_MESSAGE_COOKIE_KEY, + CONTENT_TYPE_COOKIE_KEY, +} from './utils/cookies'; +import { deserializeBody, EncTypeDeserializerMap } from './utils/serialization'; export namespace destination { export const getServerSideProps = ( @@ -69,44 +37,33 @@ export namespace destination { req.body = body.toString('utf-8'); } - const cookies = nookies.parseCookies(ctx); - const res: NextApiResponse = {}; - + const cookieManager = new CookieManager(ctx); // TODO how to properly remove cookies without leftovers? - if (STATUS_CODE_COOKIE_KEY in cookies) { - ctx.res.statusCode = Number(cookies[STATUS_CODE_COOKIE_KEY] || '200'); - nookies.destroyCookie(ctx, STATUS_CODE_COOKIE_KEY, { - path: '/', - httpOnly: true, - }); + if (cookieManager.hasCookie(STATUS_CODE_COOKIE_KEY)) { + ctx.res.statusCode = Number(cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || '200'); + cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); } - if (STATUS_MESSAGE_COOKIE_KEY in cookies) { - ctx.res.statusMessage = cookies[STATUS_MESSAGE_COOKIE_KEY] || ''; - nookies.destroyCookie(ctx, STATUS_MESSAGE_COOKIE_KEY, { - path: '/', - httpOnly: true, - }); + if (cookieManager.hasCookie(STATUS_MESSAGE_COOKIE_KEY)) { + ctx.res.statusMessage = cookieManager.getCookie(STATUS_MESSAGE_COOKIE_KEY) || ''; + cookieManager.unsetCookie(STATUS_MESSAGE_COOKIE_KEY); } - if (BODY_COOKIE_KEY in cookies) { - const resBody = cookies[BODY_COOKIE_KEY]; - if (cookies[CONTENT_TYPE_COOKIE_KEY] === ENCTYPE_APPLICATION_JSON) { + const res: NextApiResponse = {}; + if (cookieManager.hasCookie(BODY_COOKIE_KEY)) { + const resBody = cookieManager.getCookie(BODY_COOKIE_KEY); + const contentType = cookieManager.getCookie(CONTENT_TYPE_COOKIE_KEY); + if (contentType === ENCTYPE_APPLICATION_JSON) { res.body = deserialize(resBody); - } else if (cookies[CONTENT_TYPE_COOKIE_KEY] === ENCTYPE_APPLICATION_OCTET_STREAM) { + } else if (contentType === ENCTYPE_APPLICATION_OCTET_STREAM) { res.body = deserialize(resBody); } else { const c = console; c.warn('Could not parse response body, returning nothing'); + res.body = null; } - nookies.destroyCookie(ctx, BODY_COOKIE_KEY, { - path: '/', - httpOnly: true, - }); - nookies.destroyCookie(ctx, CONTENT_TYPE_COOKIE_KEY, { - path: '/', - httpOnly: true, - }); + cookieManager.unsetCookie(BODY_COOKIE_KEY); + cookieManager.unsetCookie(CONTENT_TYPE_COOKIE_KEY); } let gspResult; @@ -134,68 +91,6 @@ export namespace destination { } export namespace action { - const parseMultipartFormData = async ( - req: IncomingMessage, - ) => new Promise>((resolve, reject) => { - const body: Record = {}; - const bb = busboy({ - headers: req.headers, - }); - - bb.on('file', (name, file, info) => { - const { - filename, - mimeType: mimetype, - } = info; - - let fileData = Buffer.from(''); - - file.on('data', (data) => { - fileData = Buffer.concat([fileData, data]); - }); - - file.on('close', () => { - body[name] = new File([fileData.buffer], filename, { - type: mimetype, - }); - }); - }); - - bb.on('field', (name, value) => { - body[name] = value; - }); - - bb.on('close', () => { - resolve(body); - }); - - bb.on('error', (error) => { - reject(error); - }); - - req.pipe(bb); - }); - - const deserializeBody = async (req: IncomingMessage) => { - const contentType = req.headers['content-type']; - const encoding = (req.headers['content-encoding'] ?? 'utf-8') as BufferEncoding; - if (contentType === ENCTYPE_APPLICATION_JSON) { - const bodyRaw = await getBody(req); - return JSON.parse(bodyRaw.toString(encoding)) as Record; - } - if (contentType === ENCTYPE_X_WWW_FORM_URLENCODED) { - const bodyRaw = await getBody(req); - return Object.fromEntries( - new URLSearchParams(bodyRaw.toString(encoding)).entries(), - ); - } - if (contentType?.startsWith(`${ENCTYPE_MULTIPART_FORM_DATA};`)) { - return parseMultipartFormData(req); - } - const bodyRaw = await getBody(req); - return bodyRaw.toString('binary'); - }; - export const getApiConfig = (customConfig = {} as PageConfig) => ({ api: { ...(customConfig.api ?? {}), @@ -203,19 +98,34 @@ export namespace action { }, }); - export const wrapApiHandler = (fn: NextApiHandler): NextApiHandler => async (req, res) => { - const body = await deserializeBody(req); + export interface ActionWrapperOptions { + fn: NextApiHandler, + deserializers?: EncTypeDeserializerMap, + } + + export const wrapApiHandler = ( + options: ActionWrapperOptions, + ): NextApiHandler => async (req, res) => { + const body = await deserializeBody({ + req, + deserializers: options.deserializers, + }); const reqMut = req as unknown as Record; reqMut.body = body; - return fn(reqMut as unknown as DefaultNextApiRequest, res); + return options.fn(reqMut as unknown as DefaultNextApiRequest, res); }; - export const getServerSideProps = (fn: NextApiHandler): GetServerSideProps => async (ctx) => { + export const getServerSideProps = ( + options: ActionWrapperOptions, + ): GetServerSideProps => async (ctx) => { const { referer } = ctx.req.headers; const mockReq = { ...ctx.req, - body: await deserializeBody(ctx.req), + body: await deserializeBody({ + req: ctx.req, + deserializers: options.deserializers, + }), } as DefaultNextApiRequest; let data: unknown = null; @@ -230,7 +140,8 @@ export namespace action { return mockRes; }, send: (raw?: unknown) => { - // todo: how to transfer binary response in a more compact way? + // xtodo: how to transfer binary response in a more compact way? + // > we let seroval handle this for now if (typeof raw === 'undefined' || raw === null) { return; } @@ -247,37 +158,15 @@ export namespace action { }, } as DefaultNextApiResponse; - await fn(mockReq, mockRes); - - generateStatusCodeCookieKey(); - nookies.setCookie(ctx, STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString(), { - maxAge: 30 * 24 * 60 * 60, - path: '/', - httpOnly: true, - }); - - generateStatusMessageCookieKey(); - nookies.setCookie(ctx, STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage, { - maxAge: 30 * 24 * 60 * 60, - path: '/', - httpOnly: true, - }); + await options.fn(mockReq, mockRes); + const cookieManager = new CookieManager(ctx); + cookieManager.setCookie(STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString()); + cookieManager.setCookie(STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage); if (data) { - generateBodyCookieKey(); - nookies.setCookie(ctx, BODY_COOKIE_KEY, data as string, { - maxAge: 30 * 24 * 60 * 60, - path: '/', - httpOnly: true, - }); - + cookieManager.setCookie(BODY_COOKIE_KEY, data as string); if (contentType) { - generateContentTypeCookieKey(); - nookies.setCookie(ctx, CONTENT_TYPE_COOKIE_KEY, contentType, { - maxAge: 30 * 24 * 60 * 60, - path: '/', - httpOnly: true, - }); + cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, contentType); } } diff --git a/packages/iceform-next/src/utils/body.ts b/packages/iceform-next/src/utils/body.ts new file mode 100644 index 0000000..e047b97 --- /dev/null +++ b/packages/iceform-next/src/utils/body.ts @@ -0,0 +1,60 @@ +import { IncomingMessage } from 'http'; +import busboy from 'busboy'; + +export const getBody = (req: IncomingMessage) => new Promise((resolve, reject) => { + let body = Buffer.from(''); + + req.on('data', (chunk) => { + body = Buffer.concat([body, chunk]); + }); + + req.on('error', (err) => { + reject(err); + }); + + req.on('end', () => { + resolve(body); + }); +}); + +export const parseMultipartFormData = async ( + req: IncomingMessage, +) => new Promise>((resolve, reject) => { + const body: Record = {}; + const bb = busboy({ + headers: req.headers, + }); + + bb.on('file', (name, file, info) => { + const { + filename, + mimeType: mimetype, + } = info; + + let fileData = Buffer.from(''); + + file.on('data', (data) => { + fileData = Buffer.concat([fileData, data]); + }); + + file.on('close', () => { + body[name] = new File([fileData.buffer], filename, { + type: mimetype, + }); + }); + }); + + bb.on('field', (name, value) => { + body[name] = value; + }); + + bb.on('close', () => { + resolve(body); + }); + + bb.on('error', (error) => { + reject(error); + }); + + req.pipe(bb); +}); diff --git a/packages/iceform-next/src/utils/cookies.ts b/packages/iceform-next/src/utils/cookies.ts new file mode 100644 index 0000000..4616db9 --- /dev/null +++ b/packages/iceform-next/src/utils/cookies.ts @@ -0,0 +1,55 @@ +import { IncomingMessage, ServerResponse } from 'http'; +import * as nookies from 'nookies'; + +const COMMON_COOKIE_CONFIG = { + path: '/', + httpOnly: true, +}; + +const COMMON_SET_COOKIE_CONFIG = { + ...COMMON_COOKIE_CONFIG, + maxAge: 30 * 24 * 60 * 60, +}; + +const cookieKeys: Record = {}; + +export const BODY_COOKIE_KEY = 'b' as const; +export const STATUS_CODE_COOKIE_KEY = 'sc' as const; +export const STATUS_MESSAGE_COOKIE_KEY = 'sm' as const; +export const CONTENT_TYPE_COOKIE_KEY = 'ct' as const; + +export class CookieManager { + private readonly ctx: { req: IncomingMessage, res: ServerResponse }; + + constructor(ctx: { req: IncomingMessage, res: ServerResponse }) { + // noop + this.ctx = ctx; + } + + private static generateCookieKey(key: string) { + return `if${key}${Date.now()}`; + } + + setCookie(key: string, value: string) { + nookies.setCookie( + this.ctx, + cookieKeys[key] = CookieManager.generateCookieKey(key), + value, + COMMON_SET_COOKIE_CONFIG, + ); + } + + unsetCookie(key: string) { + nookies.destroyCookie(this.ctx, cookieKeys[key], COMMON_COOKIE_CONFIG); + } + + hasCookie(key: string) { + const cookies = nookies.parseCookies(this.ctx); + return cookieKeys[key] in cookies; + } + + getCookie(key: string) { + const cookies = nookies.parseCookies(this.ctx); + return cookies[cookieKeys[key]]; + } +} diff --git a/packages/iceform-next/src/utils/serialization.ts b/packages/iceform-next/src/utils/serialization.ts new file mode 100644 index 0000000..c8b101c --- /dev/null +++ b/packages/iceform-next/src/utils/serialization.ts @@ -0,0 +1,89 @@ +import { IncomingMessage } from 'http'; +import { getFormValues } from '@theoryofnekomata/formxtra'; +import { + ENCTYPE_APPLICATION_JSON, + ENCTYPE_MULTIPART_FORM_DATA, + ENCTYPE_X_WWW_FORM_URLENCODED, +} from '../common'; +import { getBody, parseMultipartFormData } from './body'; + +export type EncTypeSerializer = (data: unknown) => string; + +export type EncTypeSerializerMap = Record; + +export type SerializerOptions = Parameters[1]; + +export interface SerializeBodyParams { + form: HTMLFormElement, + encType: string, + serializers?: EncTypeSerializerMap, + options?: SerializerOptions, +} + +export const DEFAULT_ENCTYPE_SERIALIZERS: EncTypeSerializerMap = { + [ENCTYPE_APPLICATION_JSON]: (data: unknown) => JSON.stringify(data), +}; + +export const serializeBody = (params: SerializeBodyParams) => { + const { + form, + encType, + serializers = DEFAULT_ENCTYPE_SERIALIZERS, + options, + } = params; + + if (encType === ENCTYPE_X_WWW_FORM_URLENCODED) { + return new URLSearchParams(form); + } + + if (encType === ENCTYPE_MULTIPART_FORM_DATA) { + // type error when provided a submitter element for some reason... + const FormDataUnknown = FormData as unknown as { + new(formElement?: HTMLElement, submitter?: HTMLElement): BodyInit; + }; + return new FormDataUnknown(form, options?.submitter); + } + + if (typeof serializers[encType] === 'function') { + return serializers[encType](getFormValues(form, options)); + } + + throw new Error(`Unsupported encType: ${encType}`); +}; + +export type EncTypeDeserializer = (data: string) => unknown; + +export type EncTypeDeserializerMap = Record; + +export interface DeserializeBodyParams { + req: IncomingMessage, + deserializers?: EncTypeDeserializerMap, +} + +export const DEFAULT_ENCTYPE_DESERIALIZERS: EncTypeDeserializerMap = { + [ENCTYPE_APPLICATION_JSON]: (data: string) => JSON.parse(data) as Record, +}; + +export const deserializeBody = async (params: DeserializeBodyParams) => { + const { req, deserializers = DEFAULT_ENCTYPE_DESERIALIZERS } = params; + const contentType = req.headers['content-type'] ?? 'application/octet-stream'; + + if (contentType?.startsWith(`${ENCTYPE_MULTIPART_FORM_DATA};`)) { + return parseMultipartFormData(req); + } + + const encoding = (req.headers['content-encoding'] ?? 'utf-8') as BufferEncoding; + const bodyRaw = await getBody(req); + + if (contentType === ENCTYPE_X_WWW_FORM_URLENCODED) { + return Object.fromEntries( + new URLSearchParams(bodyRaw.toString(encoding)).entries(), + ); + } + + if (typeof deserializers[contentType] === 'function') { + return deserializers[contentType](bodyRaw.toString(encoding)); + } + + return bodyRaw.toString('binary'); +};