@@ -1,17 +0,0 @@ | |||
import {IncomingMessage} from 'http'; | |||
export const getBody = (req: IncomingMessage) => new Promise<Buffer>((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); | |||
}); | |||
}); |
@@ -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" | |||
} | |||
@@ -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", | |||
@@ -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<string, EncTypeSerializer>; | |||
const DEFAULT_ENCTYPE_SERIALIZERS: EncTypeSerializerMap = { | |||
'application/json': (data: unknown) => JSON.stringify(data), | |||
[ENCTYPE_APPLICATION_JSON]: (data: unknown) => JSON.stringify(data), | |||
}; | |||
type GetFormValuesOptions = Parameters<typeof getFormValues>[1]; | |||
interface FormProps extends Omit<BaseProps, 'action'> { | |||
export interface FormProps extends Omit<BaseProps, 'action'> { | |||
action?: string; | |||
clientAction?: string; | |||
clientHeaders?: HeadersInit; | |||
@@ -34,23 +40,25 @@ interface FormProps extends Omit<BaseProps, 'action'> { | |||
export const useResponse = (res: NextApiResponse) => { | |||
const [response, setResponse] = React.useState<Response | undefined>( | |||
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<FormDerivedElement, FormProps>(({ | |||
children, | |||
@@ -96,11 +104,11 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||
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<FormDerivedElement, FormProps>(({ | |||
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<string, string>)['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<FormDerivedElement, FormProps>(({ | |||
...serializerOptions, | |||
submitter: nativeEvent.submitter, | |||
}, | |||
}), | |||
headers, | |||
}); | |||
}); | |||
} | |||
const response = await fetch(clientAction, fetchInit); | |||
refresh?.(response); | |||
} | |||
@@ -144,7 +157,6 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||
const serverMethodRaw = method.toLowerCase(); | |||
const serverMethod = serverMethodRaw === 'get' ? 'get' : 'post'; | |||
const serverEncType = 'multipart/form-data'; | |||
return ( | |||
<FormDerivedElementComponent | |||
@@ -153,14 +165,28 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||
onSubmit={handleSubmit} | |||
action={action} | |||
method={serverMethod} | |||
encType={serverEncType} | |||
encType={ENCTYPE_MULTIPART_FORM_DATA} | |||
> | |||
{children} | |||
</FormDerivedElementComponent> | |||
); | |||
}); | |||
export type NextPage<T = {}, U = T> = 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<T = NonNullable<unknown>, U = T> = DefaultNextPage< | |||
T & { | |||
res: NextApiResponse; | |||
req: NextApiRequest; | |||
@@ -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<DefaultNextApiRequest, 'query' | 'body'>; | |||
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; |
@@ -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<Buffer>((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<string, unknown>; | |||
reqMut.body = body; | |||
return fn(reqMut as unknown as DefaultNextApiRequest, res); | |||
}; | |||
const parseMultipartFormData = async (req: IncomingMessage) => { | |||
return new Promise<Record<string, unknown>>((resolve, reject) => { | |||
const body: Record<string, unknown> = {}; | |||
const bb = busboy({ | |||
headers: req.headers, | |||
}); | |||
bb.on('file', (name, file, info) => { | |||
const { | |||
filename, | |||
mimeType: mimetype | |||
} = info; | |||
const parseMultipartFormData = async ( | |||
req: IncomingMessage, | |||
) => new Promise<Record<string, unknown>>((resolve, reject) => { | |||
const body: Record<string, unknown> = {}; | |||
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<string, unknown>; | |||
} | |||
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<string, unknown>; | |||
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 { | |||
@@ -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'} | |||