@@ -44,7 +44,7 @@ Then define the API route: | |||||
import * as Iceform from '@modal-sh/iceform-next'; | import * as Iceform from '@modal-sh/iceform-next'; | ||||
import { greet } from '@/handlers/greet'; | 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 | // you can extend the route config by passing an extra argument | ||||
export const config = Iceform.action.getApiConfig(); | export const config = Iceform.action.getApiConfig(); | ||||
@@ -66,11 +66,11 @@ const ActionGreetPage: NextPage = () => null; | |||||
export default ActionGreetPage; | export default ActionGreetPage; | ||||
export const getServerSideProps = Iceform.action.getServerSideProps(greet); | |||||
export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet }); | |||||
``` | ``` | ||||
Lastly, define the form page: | Lastly, define the form page: | ||||
```ts | |||||
```tsx | |||||
// [src/]pages/form.tsx | // [src/]pages/form.tsx | ||||
import * as React from 'react'; | 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) | - [X] Content negotiation (custom request data) | ||||
- [ ] Tests | - [ ] Tests | ||||
- [ ] Form with redirects | |||||
- [ ] Form with files | |||||
- [ ] Documentation | |||||
- [ ] Remix support | - [ ] Remix support |
@@ -6,4 +6,4 @@ const ActionGreetPage: NextPage = () => null; | |||||
export default ActionGreetPage; | export default ActionGreetPage; | ||||
export const getServerSideProps = Iceform.action.getServerSideProps(greet); | |||||
export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet }); |
@@ -1,7 +1,7 @@ | |||||
import * as Iceform from '@modal-sh/iceform-next'; | import * as Iceform from '@modal-sh/iceform-next'; | ||||
import { greet } from '@/handlers/greet'; | import { greet } from '@/handlers/greet'; | ||||
const handler = Iceform.action.wrapApiHandler(greet); | |||||
const handler = Iceform.action.wrapApiHandler({ fn: greet }); | |||||
export const config = Iceform.action.getApiConfig(); | export const config = Iceform.action.getApiConfig(); | ||||
@@ -1,5 +1,4 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { getFormValues } from '@theoryofnekomata/formxtra'; | |||||
import fetchPonyfill from 'fetch-ponyfill'; | import fetchPonyfill from 'fetch-ponyfill'; | ||||
import { NextPage as DefaultNextPage } from 'next'; | import { NextPage as DefaultNextPage } from 'next'; | ||||
import { | import { | ||||
@@ -7,35 +6,41 @@ import { | |||||
NextApiRequest, | NextApiRequest, | ||||
ENCTYPE_APPLICATION_JSON, | ENCTYPE_APPLICATION_JSON, | ||||
ENCTYPE_MULTIPART_FORM_DATA, | ENCTYPE_MULTIPART_FORM_DATA, | ||||
ENCTYPE_X_WWW_FORM_URLENCODED, | |||||
} from './common'; | } from './common'; | ||||
import { | |||||
DEFAULT_ENCTYPE_SERIALIZERS, | |||||
EncTypeSerializerMap, serializeBody, | |||||
SerializerOptions, | |||||
} from './utils/serialization'; | |||||
const FormDerivedElementComponent = 'form' as const; | const FormDerivedElementComponent = 'form' as const; | ||||
type FormDerivedElement = HTMLElementTagNameMap[typeof FormDerivedElementComponent]; | type FormDerivedElement = HTMLElementTagNameMap[typeof FormDerivedElementComponent]; | ||||
type BaseProps = React.HTMLProps<FormDerivedElement>; | |||||
type EncTypeSerializer = (data: unknown) => string; | |||||
const ALLOWED_SERVER_METHODS = ['get', 'post'] as const; | |||||
type EncTypeSerializerMap = Record<string, EncTypeSerializer>; | |||||
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<typeof getFormValues>[1]; | |||||
type AllowedClientMethod = typeof ALLOWED_CLIENT_METHODS[number]; | |||||
export interface FormProps extends Omit<BaseProps, 'action'> { | |||||
export interface FormProps extends Omit<React.HTMLProps<FormDerivedElement>, 'action' | 'method'> { | |||||
action?: string; | action?: string; | ||||
method?: AllowedServerMethod; | |||||
clientAction?: string; | clientAction?: string; | ||||
clientHeaders?: HeadersInit; | clientHeaders?: HeadersInit; | ||||
clientMethod?: BaseProps['method']; | |||||
clientMethod?: AllowedClientMethod; | |||||
invalidate?: (...args: unknown[]) => unknown; | invalidate?: (...args: unknown[]) => unknown; | ||||
refresh?: (response: Response) => void; | refresh?: (response: Response) => void; | ||||
encTypeSerializers?: EncTypeSerializerMap; | encTypeSerializers?: EncTypeSerializerMap; | ||||
responseEncType?: string; | responseEncType?: string; | ||||
serializerOptions?: GetFormValuesOptions; | |||||
serializerOptions?: SerializerOptions; | |||||
} | } | ||||
export const useResponse = (res: NextApiResponse) => { | 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<FormDerivedElement, FormProps>(({ | export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | ||||
children, | children, | ||||
onSubmit, | onSubmit, | ||||
@@ -151,10 +122,6 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||||
onSubmit?.(event); | 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 serverMethodRaw = method.toLowerCase(); | ||||
const serverMethod = serverMethodRaw === 'get' ? 'get' : 'post'; | const serverMethod = serverMethodRaw === 'get' ? 'get' : 'post'; | ||||
@@ -181,6 +148,7 @@ Form.defaultProps = { | |||||
clientMethod: undefined, | clientMethod: undefined, | ||||
encTypeSerializers: DEFAULT_ENCTYPE_SERIALIZERS, | encTypeSerializers: DEFAULT_ENCTYPE_SERIALIZERS, | ||||
invalidate: undefined, | invalidate: undefined, | ||||
method: 'get' as const, | |||||
refresh: undefined, | refresh: undefined, | ||||
responseEncType: ENCTYPE_APPLICATION_JSON, | responseEncType: ENCTYPE_APPLICATION_JSON, | ||||
serializerOptions: undefined, | serializerOptions: undefined, | ||||
@@ -5,54 +5,22 @@ import { | |||||
NextApiResponse as DefaultNextApiResponse, | NextApiResponse as DefaultNextApiResponse, | ||||
PageConfig, | PageConfig, | ||||
} from 'next'; | } from 'next'; | ||||
import * as nookies from 'nookies'; | |||||
import { IncomingMessage } from 'http'; | |||||
import busboy from 'busboy'; | |||||
import { deserialize, serialize } from 'seroval'; | import { deserialize, serialize } from 'seroval'; | ||||
import { | import { | ||||
NextApiResponse, | NextApiResponse, | ||||
NextApiRequest, | NextApiRequest, | ||||
ENCTYPE_APPLICATION_JSON, | ENCTYPE_APPLICATION_JSON, | ||||
ENCTYPE_X_WWW_FORM_URLENCODED, | |||||
ENCTYPE_MULTIPART_FORM_DATA, ENCTYPE_APPLICATION_OCTET_STREAM, | |||||
ENCTYPE_APPLICATION_OCTET_STREAM, | |||||
} from './common'; | } 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<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); | |||||
}); | |||||
}); | |||||
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 namespace destination { | ||||
export const getServerSideProps = ( | export const getServerSideProps = ( | ||||
@@ -69,44 +37,33 @@ export namespace destination { | |||||
req.body = body.toString('utf-8'); | 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? | // 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); | 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); | res.body = deserialize(resBody); | ||||
} else { | } else { | ||||
const c = console; | const c = console; | ||||
c.warn('Could not parse response body, returning nothing'); | 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; | let gspResult; | ||||
@@ -134,68 +91,6 @@ export namespace destination { | |||||
} | } | ||||
export namespace action { | export namespace action { | ||||
const parseMultipartFormData = async ( | |||||
req: IncomingMessage, | |||||
) => 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; | |||||
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<string, unknown>; | |||||
} | |||||
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) => ({ | export const getApiConfig = (customConfig = {} as PageConfig) => ({ | ||||
api: { | api: { | ||||
...(customConfig.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<string, unknown>; | const reqMut = req as unknown as Record<string, unknown>; | ||||
reqMut.body = body; | 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 { referer } = ctx.req.headers; | ||||
const mockReq = { | const mockReq = { | ||||
...ctx.req, | ...ctx.req, | ||||
body: await deserializeBody(ctx.req), | |||||
body: await deserializeBody({ | |||||
req: ctx.req, | |||||
deserializers: options.deserializers, | |||||
}), | |||||
} as DefaultNextApiRequest; | } as DefaultNextApiRequest; | ||||
let data: unknown = null; | let data: unknown = null; | ||||
@@ -230,7 +140,8 @@ export namespace action { | |||||
return mockRes; | return mockRes; | ||||
}, | }, | ||||
send: (raw?: unknown) => { | 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) { | if (typeof raw === 'undefined' || raw === null) { | ||||
return; | return; | ||||
} | } | ||||
@@ -247,37 +158,15 @@ export namespace action { | |||||
}, | }, | ||||
} as DefaultNextApiResponse; | } 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) { | 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) { | 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); | |||||
} | } | ||||
} | } | ||||
@@ -0,0 +1,60 @@ | |||||
import { IncomingMessage } from 'http'; | |||||
import busboy from 'busboy'; | |||||
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); | |||||
}); | |||||
}); | |||||
export const parseMultipartFormData = async ( | |||||
req: IncomingMessage, | |||||
) => 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; | |||||
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); | |||||
}); |
@@ -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<string, string> = {}; | |||||
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<IncomingMessage> }; | |||||
constructor(ctx: { req: IncomingMessage, res: ServerResponse<IncomingMessage> }) { | |||||
// 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]]; | |||||
} | |||||
} |
@@ -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<string, EncTypeSerializer>; | |||||
export type SerializerOptions = Parameters<typeof getFormValues>[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<string, EncTypeDeserializer>; | |||||
export interface DeserializeBodyParams { | |||||
req: IncomingMessage, | |||||
deserializers?: EncTypeDeserializerMap, | |||||
} | |||||
export const DEFAULT_ENCTYPE_DESERIALIZERS: EncTypeDeserializerMap = { | |||||
[ENCTYPE_APPLICATION_JSON]: (data: string) => JSON.parse(data) as Record<string, unknown>, | |||||
}; | |||||
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'); | |||||
}; |