diff --git a/packages/iceform-next-sandbox/src/utils/body.ts b/packages/iceform-next-sandbox/src/utils/body.ts deleted file mode 100644 index f41b07b..0000000 --- a/packages/iceform-next-sandbox/src/utils/body.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {IncomingMessage} from 'http'; - -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); - }); -}); diff --git a/packages/iceform-next/.eslintrc b/packages/iceform-next/.eslintrc index 7d22244..722c768 100644 --- a/packages/iceform-next/.eslintrc +++ b/packages/iceform-next/.eslintrc @@ -3,6 +3,15 @@ "extends": [ "lxsmnsyc/typescript/react" ], + "rules": { + "no-tabs": "off", + "indent": ["error", "tab"], + "react/jsx-indent-props": ["error", "tab"], + "react/jsx-indent": ["error", "tab"], + "react/jsx-props-no-spreading": "off", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/no-namespace": "off" + }, "parserOptions": { "project": "./tsconfig.eslint.json" } diff --git a/packages/iceform-next/package.json b/packages/iceform-next/package.json index 7c7742f..60effa2 100644 --- a/packages/iceform-next/package.json +++ b/packages/iceform-next/package.json @@ -67,7 +67,8 @@ "@theoryofnekomata/formxtra": "^1.0.3", "busboy": "^1.6.0", "fetch-ponyfill": "^7.1.0", - "nookies": "^2.5.2" + "nookies": "^2.5.2", + "seroval": "^0.9.0" }, "types": "./dist/types/index.d.ts", "main": "./dist/cjs/production/index.js", diff --git a/packages/iceform-next/src/client.tsx b/packages/iceform-next/src/client.tsx index 3e0606a..52fd925 100644 --- a/packages/iceform-next/src/client.tsx +++ b/packages/iceform-next/src/client.tsx @@ -1,8 +1,14 @@ import * as React from 'react'; import { getFormValues } from '@theoryofnekomata/formxtra'; import fetchPonyfill from 'fetch-ponyfill'; -import {NextPage as DefaultNextPage} from 'next'; -import {NextApiResponse, NextApiRequest} from './common'; +import { NextPage as DefaultNextPage } from 'next'; +import { + NextApiResponse, + NextApiRequest, + ENCTYPE_APPLICATION_JSON, + ENCTYPE_MULTIPART_FORM_DATA, + ENCTYPE_X_WWW_FORM_URLENCODED, +} from './common'; const FormDerivedElementComponent = 'form' as const; @@ -15,12 +21,12 @@ type EncTypeSerializer = (data: unknown) => string; type EncTypeSerializerMap = Record; const DEFAULT_ENCTYPE_SERIALIZERS: EncTypeSerializerMap = { - 'application/json': (data: unknown) => JSON.stringify(data), + [ENCTYPE_APPLICATION_JSON]: (data: unknown) => JSON.stringify(data), }; type GetFormValuesOptions = Parameters[1]; -interface FormProps extends Omit { +export interface FormProps extends Omit { action?: string; clientAction?: string; clientHeaders?: HeadersInit; @@ -34,23 +40,25 @@ interface FormProps extends Omit { export const useResponse = (res: NextApiResponse) => { const [response, setResponse] = React.useState( - res.body ? new Response(res.body as any) : undefined + res.body ? new Response(res.body as unknown as BodyInit) : undefined, ); - const onStale = React.useCallback(() => { + const invalidate = React.useCallback(() => { setResponse(undefined); }, []); - const onFresh = React.useCallback((response: Response) => { - setResponse(response); + const refresh = React.useCallback((newResponse: Response) => { + setResponse(newResponse); }, []); return React.useMemo(() => ({ response, - refresh: onFresh, - invalidate: onStale, + refresh, + invalidate, }), [ response, + refresh, + invalidate, ]); }; @@ -66,18 +74,18 @@ const serializeBody = (params: SerializeBodyParams) => { form, encType, serializers, - options + options, } = params; - if (encType === 'multipart/form-data') { + if (encType === ENCTYPE_MULTIPART_FORM_DATA) { // type error when provided a submitter element for some reason... const FormDataUnknown = FormData as unknown as { - new(form?: HTMLElement, submitter?: HTMLElement ): BodyInit; + new(formElement?: HTMLElement, submitter?: HTMLElement): BodyInit; }; return new FormDataUnknown(form, options?.submitter); } - if (encType === 'application/x-www-form-urlencoded') { + if (encType === ENCTYPE_X_WWW_FORM_URLENCODED) { return new URLSearchParams(form); } @@ -86,7 +94,7 @@ const serializeBody = (params: SerializeBodyParams) => { } throw new Error(`Unsupported encType: ${encType}`); -} +}; export const Form = React.forwardRef(({ children, @@ -96,11 +104,11 @@ export const Form = React.forwardRef(({ clientAction = action, clientMethod = method, clientHeaders, - encType = 'multipart/form-data', + encType = ENCTYPE_MULTIPART_FORM_DATA, invalidate, refresh, encTypeSerializers = DEFAULT_ENCTYPE_SERIALIZERS, - responseEncType = 'application/json', + responseEncType = ENCTYPE_APPLICATION_JSON, serializerOptions, ...etcProps }, forwardedRef) => { @@ -113,15 +121,20 @@ export const Form = React.forwardRef(({ const { fetch } = fetchPonyfill(); const headers: HeadersInit = { ...(clientHeaders ?? {}), - 'Accept': responseEncType, + Accept: responseEncType, }; - if (encType !== 'multipart/form-data') { + if (encType !== ENCTYPE_MULTIPART_FORM_DATA) { // browser automatically generates content-type header for multipart/form-data (headers as unknown as Record)['Content-Type'] = encType; } - const response = await fetch(clientAction, { + + const fetchInit: RequestInit = { method: clientMethod.toUpperCase(), - body: serializeBody({ + headers, + }; + + if (!['GET', 'HEAD'].includes(clientMethod.toUpperCase())) { + fetchInit.body = serializeBody({ form: event.currentTarget, encType, serializers: encTypeSerializers, @@ -129,9 +142,9 @@ export const Form = React.forwardRef(({ ...serializerOptions, submitter: nativeEvent.submitter, }, - }), - headers, - }); + }); + } + const response = await fetch(clientAction, fetchInit); refresh?.(response); } @@ -144,7 +157,6 @@ export const Form = React.forwardRef(({ const serverMethodRaw = method.toLowerCase(); const serverMethod = serverMethodRaw === 'get' ? 'get' : 'post'; - const serverEncType = 'multipart/form-data'; return ( (({ onSubmit={handleSubmit} action={action} method={serverMethod} - encType={serverEncType} + encType={ENCTYPE_MULTIPART_FORM_DATA} > {children} ); }); -export type NextPage = DefaultNextPage< +Form.displayName = 'Form' as const; + +Form.defaultProps = { + action: undefined, + clientAction: undefined, + clientHeaders: undefined, + clientMethod: undefined, + encTypeSerializers: DEFAULT_ENCTYPE_SERIALIZERS, + invalidate: undefined, + refresh: undefined, + responseEncType: ENCTYPE_APPLICATION_JSON, + serializerOptions: undefined, +}; + +export type NextPage, U = T> = DefaultNextPage< T & { res: NextApiResponse; req: NextApiRequest; diff --git a/packages/iceform-next/src/common.ts b/packages/iceform-next/src/common.ts index aa8a26d..ad10f35 100644 --- a/packages/iceform-next/src/common.ts +++ b/packages/iceform-next/src/common.ts @@ -1,10 +1,17 @@ -import {ParsedUrlQuery} from 'querystring'; +import { + NextApiRequest as DefaultNextApiRequest, +} from 'next'; -export interface NextApiRequest { - query: ParsedUrlQuery; - body?: unknown; -} +export type NextApiRequest = Pick; export interface NextApiResponse { body?: unknown; } + +export const ENCTYPE_APPLICATION_JSON = 'application/json' as const; + +export const ENCTYPE_APPLICATION_OCTET_STREAM = 'application/octet-stream' as const; + +export const ENCTYPE_MULTIPART_FORM_DATA = 'multipart/form-data' as const; + +export const ENCTYPE_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded' as const; diff --git a/packages/iceform-next/src/server.ts b/packages/iceform-next/src/server.ts index 6c6fb42..db52b66 100644 --- a/packages/iceform-next/src/server.ts +++ b/packages/iceform-next/src/server.ts @@ -2,17 +2,29 @@ import { GetServerSideProps, NextApiHandler, NextApiRequest as DefaultNextApiRequest, - NextApiResponse as DefaultNextApiResponse, PageConfig, + NextApiResponse as DefaultNextApiResponse, + PageConfig, } from 'next'; import * as nookies from 'nookies'; -import {IncomingMessage} from 'http'; +import { IncomingMessage } from 'http'; import busboy from 'busboy'; -import {NextApiResponse, NextApiRequest} from './common'; - - -let BODY_COOKIE_KEY : string; +import { deserialize, serialize } from 'seroval'; +import { + NextApiResponse, + NextApiRequest, + ENCTYPE_APPLICATION_JSON, + ENCTYPE_X_WWW_FORM_URLENCODED, + ENCTYPE_MULTIPART_FORM_DATA, 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()}`; @@ -43,9 +55,12 @@ const getBody = (req: IncomingMessage) => new Promise((resolve, reject) }); export namespace destination { - export const getServerSideProps = (gspFn?: GetServerSideProps): GetServerSideProps => async (ctx) => { + export const getServerSideProps = ( + gspFn?: GetServerSideProps, + ): GetServerSideProps => async (ctx) => { const req: NextApiRequest = { query: ctx.query, + body: null, }; const { method = 'GET' } = ctx.req; @@ -57,10 +72,12 @@ export namespace destination { const cookies = nookies.parseCookies(ctx); const res: NextApiResponse = {}; + // 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, }); } @@ -68,26 +85,33 @@ export namespace destination { ctx.res.statusMessage = cookies[STATUS_MESSAGE_COOKIE_KEY] || ''; nookies.destroyCookie(ctx, STATUS_MESSAGE_COOKIE_KEY, { path: '/', + httpOnly: true, }); } if (BODY_COOKIE_KEY in cookies) { const resBody = cookies[BODY_COOKIE_KEY]; - if (resBody.startsWith('json:')) { - res.body = JSON.parse(resBody.slice(5)); - } else if (resBody.startsWith('raw:')) { - res.body = resBody.slice(4); + if (cookies[CONTENT_TYPE_COOKIE_KEY] === ENCTYPE_APPLICATION_JSON) { + res.body = deserialize(resBody); + } else if (cookies[CONTENT_TYPE_COOKIE_KEY] === ENCTYPE_APPLICATION_OCTET_STREAM) { + res.body = deserialize(resBody); } else { - console.warn('Could not parse response body, returning nothing'); + const c = console; + c.warn('Could not parse response body, returning nothing'); } nookies.destroyCookie(ctx, BODY_COOKIE_KEY, { path: '/', + httpOnly: true, + }); + nookies.destroyCookie(ctx, CONTENT_TYPE_COOKIE_KEY, { + path: '/', + httpOnly: true, }); } let gspResult; if (gspFn) { - gspResult = await gspFn(ctx) + gspResult = await gspFn(ctx); } else { gspResult = { props: {}, @@ -110,84 +134,82 @@ export namespace destination { } export namespace action { - export const getApiConfig = (customConfig = {} as PageConfig) => ({ - api: { - ...(customConfig.api ?? {}), - bodyParser: false, - }, - }); - - export const wrapApiHandler = (fn: NextApiHandler): NextApiHandler => async (req, res) => { - const body = await deserializeBody(req); - const reqMut = req as unknown as Record; - reqMut.body = body; - return fn(reqMut as unknown as DefaultNextApiRequest, res); - }; - - const parseMultipartFormData = async (req: IncomingMessage) => { - return new Promise>((resolve, reject) => { - const body: Record = {}; - const bb = busboy({ - headers: req.headers, - }); - - bb.on('file', (name, file, info) => { - const { - filename, - mimeType: mimetype - } = info; + const parseMultipartFormData = async ( + req: IncomingMessage, + ) => new Promise>((resolve, reject) => { + const body: Record = {}; + const bb = busboy({ + headers: req.headers, + }); - let fileData = Buffer.from(''); + bb.on('file', (name, file, info) => { + const { + filename, + mimeType: mimetype, + } = info; - file.on('data', (data) => { - fileData = Buffer.concat([fileData, data]); - }); + let fileData = Buffer.from(''); - file.on('close', () => { - body[name] = new File([fileData.buffer], filename, { - type: mimetype, - }); - }); + file.on('data', (data) => { + fileData = Buffer.concat([fileData, data]); }); - bb.on('field', (name, value) => { - body[name] = value; + file.on('close', () => { + body[name] = new File([fileData.buffer], filename, { + type: mimetype, + }); }); + }); - bb.on('close', () => { - resolve(body); - }); + bb.on('field', (name, value) => { + body[name] = value; + }); - bb.on('error', (error) => { - reject(error); - }); + bb.on('close', () => { + resolve(body); + }); - req.pipe(bb); + bb.on('error', (error) => { + reject(error); }); - }; + + req.pipe(bb); + }); const deserializeBody = async (req: IncomingMessage) => { const contentType = req.headers['content-type']; - // TODO get body encoding from headers const encoding = (req.headers['content-encoding'] ?? 'utf-8') as BufferEncoding; - // should we turn off default body parsing? - if (contentType === 'application/json') { + if (contentType === ENCTYPE_APPLICATION_JSON) { const bodyRaw = await getBody(req); - return JSON.parse(bodyRaw.toString(encoding)); + return JSON.parse(bodyRaw.toString(encoding)) as Record; } - if (contentType === 'application/x-www-form-urlencoded') { + if (contentType === ENCTYPE_X_WWW_FORM_URLENCODED) { const bodyRaw = await getBody(req); return Object.fromEntries( - new URLSearchParams(bodyRaw.toString(encoding)).entries() + new URLSearchParams(bodyRaw.toString(encoding)).entries(), ); } - if (contentType?.startsWith('multipart/form-data;')) { + 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 ?? {}), + bodyParser: false, + }, + }); + + export const wrapApiHandler = (fn: NextApiHandler): NextApiHandler => async (req, res) => { + const body = await deserializeBody(req); + const reqMut = req as unknown as Record; + reqMut.body = body; + return fn(reqMut as unknown as DefaultNextApiRequest, res); + }; + export const getServerSideProps = (fn: NextApiHandler): GetServerSideProps => async (ctx) => { const { referer } = ctx.req.headers; @@ -196,7 +218,8 @@ export namespace action { body: await deserializeBody(ctx.req), } as DefaultNextApiRequest; - let data = null; + let data: unknown = null; + let contentType: string | undefined; const mockRes = { // todo handle other nextapiresponse methods (e.g. setting headers, writeHead, etc.) statusMessage: '', @@ -206,12 +229,21 @@ export namespace action { this.statusCode = code; return mockRes; }, - send: (raw: any) => { + send: (raw?: unknown) => { // todo: how to transfer binary response in a more compact way? - data = `raw:${raw.toString('base64')}`; + if (typeof raw === 'undefined' || raw === null) { + return; + } + + if (raw instanceof Buffer) { + contentType = ENCTYPE_APPLICATION_OCTET_STREAM; + } + + data = serialize(raw); }, - json: (raw: any) => { - data = `json:${JSON.stringify(raw)}`; + json: (raw: unknown) => { + contentType = ENCTYPE_APPLICATION_JSON; + data = serialize(raw); }, } as DefaultNextApiResponse; @@ -221,20 +253,32 @@ export namespace action { 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, }); if (data) { generateBodyCookieKey(); - nookies.setCookie(ctx, BODY_COOKIE_KEY, data, { + nookies.setCookie(ctx, BODY_COOKIE_KEY, data as string, { maxAge: 30 * 24 * 60 * 60, path: '/', + httpOnly: true, }); + + if (contentType) { + generateContentTypeCookieKey(); + nookies.setCookie(ctx, CONTENT_TYPE_COOKIE_KEY, contentType, { + maxAge: 30 * 24 * 60 * 60, + path: '/', + httpOnly: true, + }); + } } return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1ac4fb..a78c785 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: nookies: specifier: ^2.5.2 version: 2.5.2 + seroval: + specifier: ^0.9.0 + version: 0.9.0 devDependencies: '@testing-library/jest-dom': specifier: ^5.16.5 @@ -4774,6 +4777,11 @@ packages: - supports-color dev: true + /seroval@0.9.0: + resolution: {integrity: sha512-Ttr96/8czi3SXjbFFzpRc2Xpp1wvBufmaNuTviUL8eGQhUr1mdeiQ6YYSaLnMwMc4YWSeBggq72bKEBVu6/IFA==} + engines: {node: '>=10'} + dev: false + /serve-static@1.15.0: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'}