Use filesystem-based cache to get response details instead of using the cookies (only use cookies for getting the request ID [FIXME]).master
@@ -127,9 +127,10 @@ In theory, any API route may have a corresponding action route. | |||
- [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625) | |||
- [ ] Tests | |||
- [X] Form with redirects | |||
- [ ] Form with files | |||
- [X] Form with files | |||
- [ ] Documentation | |||
- [ ] Remix support | |||
- [ ] `accept-charset=""` attribute support | |||
- [X] Method override support | |||
- Integration with Next router (iceform-next) | |||
- [ ] Integration with Next router (iceform-next) | |||
- [ ] Investigate bug of not cleaning response body cache and flash messages via cookies |
@@ -4,6 +4,11 @@ const nextConfig = { | |||
experimental: { | |||
optimizeCss: true, | |||
}, | |||
webpack: (config) => { | |||
config.resolve.fallback = { fs: false }; | |||
return config; | |||
}, | |||
}; | |||
module.exports = nextConfig; |
@@ -51,7 +51,7 @@ const patchNote: NextApiHandler = async (req, res) => { | |||
return; | |||
} | |||
const { title, content } = req.body; | |||
const { title, content, image } = req.body; | |||
const dataRaw = await fs.readFile('.db/notes.jsonl', { | |||
encoding: 'utf-8', | |||
@@ -75,6 +75,7 @@ const patchNote: NextApiHandler = async (req, res) => { | |||
...note, | |||
title: title ?? note.title, | |||
content: content ?? note.content, | |||
image: image ?? note.image, | |||
}; | |||
data[noteIndex] = updatedNote; | |||
@@ -185,7 +186,7 @@ export interface NoteCollectionParams { | |||
} | |||
const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, res) => { | |||
const { title, content } = req.body; | |||
const { title, content, image } = req.body; | |||
if (typeof title !== 'string' || typeof content !== 'string') { | |||
res.status(400).send('Bad Request'); | |||
@@ -202,6 +203,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||
id: newId, | |||
title, | |||
content, | |||
image: `data:${image.type};base64,${image.toString('base64')}`, | |||
})}\n`, | |||
{ | |||
flag: 'a', | |||
@@ -223,6 +225,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||
id: newId, | |||
title, | |||
content, | |||
image, | |||
}); | |||
}; | |||
@@ -72,6 +72,10 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
/> | |||
</div> | |||
<div> | |||
<img | |||
src={body.image as string} | |||
alt={body.title as string} | |||
/> | |||
<label> | |||
<span className="after:block">Image</span> | |||
<input type="file" name="image" /> | |||
@@ -108,13 +108,24 @@ const NotesPage: NextPage<NotesPageProps> = ({ | |||
</div> | |||
</Iceform.Form> | |||
</div> | |||
<div | |||
className="font-bold" | |||
> | |||
{note.title} | |||
</div> | |||
<div> | |||
{note.content} | |||
<div className="grid grid-cols-3 gap-4"> | |||
<div> | |||
<img | |||
className="w-full" | |||
src={note.image} | |||
alt={note.title} | |||
/> | |||
</div> | |||
<div className="col-span-2"> | |||
<div | |||
className="font-bold" | |||
> | |||
{note.title} | |||
</div> | |||
<div> | |||
{note.content} | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
))} | |||
@@ -10,7 +10,9 @@ | |||
"react/jsx-indent": ["error", "tab"], | |||
"react/jsx-props-no-spreading": "off", | |||
"@typescript-eslint/no-misused-promises": "off", | |||
"@typescript-eslint/no-namespace": "off" | |||
"@typescript-eslint/no-namespace": "off", | |||
"max-classes-per-file": "off", | |||
"import/prefer-default-export": "off" | |||
}, | |||
"parserOptions": { | |||
"project": "./tsconfig.eslint.json" | |||
@@ -68,13 +68,11 @@ | |||
}, | |||
"dependencies": { | |||
"@theoryofnekomata/formxtra": "^1.0.3", | |||
"@web-std/file": "^3.0.3", | |||
"busboy": "^1.6.0", | |||
"nookies": "^2.5.2", | |||
"seroval": "^0.9.0" | |||
"seroval": "^0.10.2" | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js", | |||
"exports": { | |||
".": { | |||
"development": { | |||
@@ -88,5 +86,8 @@ | |||
}, | |||
"typesVersions": { | |||
"*": {} | |||
} | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js" | |||
} |
@@ -1,3 +1,3 @@ | |||
{ | |||
"target": "es2018" | |||
} | |||
} |
@@ -1,148 +0,0 @@ | |||
import { | |||
GetServerSideProps, | |||
NextApiHandler, | |||
PageConfig, | |||
NextApiRequest as DefaultNextApiRequest, PreviewData, GetServerSidePropsContext, | |||
} from 'next'; | |||
import { ParsedUrlQuery } from 'querystring'; | |||
import { deserializeBody, EncTypeDeserializerMap } from '../utils/serialization'; | |||
import { IceformNextServerResponse } from './response'; | |||
import { | |||
BODY_COOKIE_KEY, CONTENT_TYPE_COOKIE_KEY, | |||
CookieManager, | |||
STATUS_CODE_COOKIE_KEY, | |||
STATUS_MESSAGE_COOKIE_KEY, | |||
} from '../utils/cookies'; | |||
import { | |||
METHOD_FORM_KEY, METHODS_WITH_BODY, | |||
PREVENT_REDIRECT_FORM_KEY, | |||
} from '../common/constants'; | |||
import { | |||
ACTION_STATUS_CODE, | |||
DEFAULT_METHOD, | |||
} from './constants'; | |||
export const getApiConfig = (customConfig = {} as PageConfig) => ({ | |||
api: { | |||
...(customConfig.api ?? {}), | |||
bodyParser: false, | |||
}, | |||
}); | |||
export type OnActionFunction< | |||
Props extends Record<string, unknown> = Record<string, unknown>, | |||
Params extends ParsedUrlQuery = ParsedUrlQuery, | |||
Preview extends PreviewData = PreviewData, | |||
> = ( | |||
actionReq: DefaultNextApiRequest, | |||
actionRes: IceformNextServerResponse, | |||
context: GetServerSidePropsContext<Params, Preview>, | |||
) => Promise<Awaited<ReturnType<GetServerSideProps<Props, Params, Preview>>> | undefined>; | |||
export interface ActionWrapperOptions { | |||
fn: NextApiHandler, | |||
onAction?: OnActionFunction, | |||
deserializers?: EncTypeDeserializerMap, | |||
/** | |||
* Maps the Location header from the handler response to an accessible URL. | |||
* @param url | |||
*/ | |||
mapLocationToRedirectDestination?: (referer: string, url: string) => string, | |||
} | |||
export const wrapApiHandler = ( | |||
options: ActionWrapperOptions, | |||
): NextApiHandler => async (req, res) => { | |||
const reqMut = req as unknown as Record<string, unknown>; | |||
if (METHODS_WITH_BODY.includes(req.method?.toUpperCase() as typeof METHODS_WITH_BODY[number])) { | |||
reqMut.body = await deserializeBody({ | |||
req, | |||
deserializers: options.deserializers, | |||
}); | |||
} | |||
return options.fn(reqMut as unknown as DefaultNextApiRequest, res); | |||
}; | |||
export const getServerSideProps = ( | |||
options: ActionWrapperOptions, | |||
): GetServerSideProps => async (ctx) => { | |||
const { referer = '/' } = ctx.req.headers; | |||
const deserialized = await deserializeBody({ | |||
req: ctx.req, | |||
deserializers: options.deserializers, | |||
}); | |||
const defaultMethod = ctx.req.method ?? DEFAULT_METHOD; | |||
let effectiveMethod = defaultMethod; | |||
let mockReqBody: unknown = deserialized; | |||
if (typeof deserialized === 'object' && deserialized !== null) { | |||
const { | |||
[METHOD_FORM_KEY]: theMethod, | |||
...theMockReqBody | |||
} = deserialized as { | |||
[METHOD_FORM_KEY]?: string, | |||
[key: string]: unknown, | |||
}; | |||
effectiveMethod = theMethod ?? defaultMethod; | |||
mockReqBody = theMockReqBody; | |||
} | |||
const mockReq = { | |||
...ctx.req, | |||
body: mockReqBody, | |||
query: { | |||
...ctx.query, | |||
...(ctx.params ?? {}), | |||
}, | |||
// ?: how to prevent malicious method spoofing? | |||
method: effectiveMethod, | |||
} as DefaultNextApiRequest; | |||
const mockRes = new IceformNextServerResponse(ctx.req); | |||
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 (mockRes.data) { | |||
cookieManager.setCookie(BODY_COOKIE_KEY, mockRes.data as string); | |||
if (mockRes.contentType) { | |||
cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, mockRes.contentType); | |||
} | |||
} | |||
if (typeof options.onAction === 'function') { | |||
const onActionResult = await options.onAction( | |||
mockReq, | |||
mockRes, | |||
ctx, | |||
); | |||
if (onActionResult) { | |||
return onActionResult; | |||
} | |||
} | |||
const preventRedirect = ( | |||
typeof mockReq.body === 'object' | |||
&& mockReq.body !== null | |||
&& PREVENT_REDIRECT_FORM_KEY in mockReq.body | |||
); | |||
const redirectDestination = ( | |||
mockRes.location | |||
&& typeof options.mapLocationToRedirectDestination === 'function' | |||
&& !preventRedirect | |||
) | |||
? options.mapLocationToRedirectDestination(referer, mockRes.location) | |||
: referer; | |||
return { | |||
redirect: { | |||
destination: redirectDestination, | |||
statusCode: ACTION_STATUS_CODE, | |||
}, | |||
props: { | |||
query: ctx.query, | |||
body: mockRes.data, | |||
}, | |||
}; | |||
}; |
@@ -0,0 +1,34 @@ | |||
import { NextApiHandler, PageConfig } from 'next'; | |||
import { NextApiRequest as DefaultNextApiRequest } from 'next/dist/shared/lib/utils'; | |||
import { ActionWrapperOptions } from './common'; | |||
import { METHODS_WITH_BODY } from '../../common/constants'; | |||
import { deserializeFormObjectBody } from '../../utils/serialization'; | |||
/** | |||
* Wraps the API handler's `config` export to support all content types. | |||
* @param customConfig | |||
*/ | |||
export const getApiConfig = (customConfig = {} as PageConfig) => ({ | |||
api: { | |||
...(customConfig.api ?? {}), | |||
bodyParser: false, | |||
}, | |||
}); | |||
/** | |||
* Wraps the API handler to support script. | |||
* @param options | |||
*/ | |||
export const wrapApiHandler = (options: ActionWrapperOptions): NextApiHandler => async ( | |||
req, | |||
res, | |||
) => { | |||
const reqMut = req as unknown as Record<string, unknown>; | |||
if (METHODS_WITH_BODY.includes(req.method?.toUpperCase() as typeof METHODS_WITH_BODY[number])) { | |||
reqMut.body = await deserializeFormObjectBody({ | |||
req, | |||
deserializers: options.deserializers, | |||
}); | |||
} | |||
return options.fn(reqMut as unknown as DefaultNextApiRequest, res); | |||
}; |
@@ -0,0 +1,31 @@ | |||
import { | |||
GetServerSideProps, | |||
GetServerSidePropsContext, | |||
NextApiHandler, | |||
PreviewData, | |||
} from 'next'; | |||
import { ParsedUrlQuery } from 'querystring'; | |||
import { NextApiRequest as DefaultNextApiRequest } from 'next/dist/shared/lib/utils'; | |||
import { EncTypeDeserializerMap } from '../../utils/serialization'; | |||
import { IceformNextServerResponse } from '../response'; | |||
export type OnActionFunction< | |||
Props extends Record<string, unknown> = Record<string, unknown>, | |||
Params extends ParsedUrlQuery = ParsedUrlQuery, | |||
Preview extends PreviewData = PreviewData, | |||
> = ( | |||
actionReq: DefaultNextApiRequest, | |||
actionRes: IceformNextServerResponse, | |||
context: GetServerSidePropsContext<Params, Preview>, | |||
) => Promise<Awaited<ReturnType<GetServerSideProps<Props, Params, Preview>>> | undefined>; | |||
export interface ActionWrapperOptions { | |||
fn: NextApiHandler, | |||
onAction?: OnActionFunction, | |||
deserializers?: EncTypeDeserializerMap, | |||
/** | |||
* Maps the Location header from the handler response to an accessible URL. | |||
* @param url | |||
*/ | |||
mapLocationToRedirectDestination?: (referer: string, url: string) => string, | |||
} |
@@ -0,0 +1,135 @@ | |||
import { IncomingMessage } from 'http'; | |||
import { GetServerSideProps } from 'next'; | |||
import { NextApiRequest as DefaultNextApiRequest } from 'next/dist/shared/lib/utils'; | |||
import crypto from 'crypto'; | |||
import { | |||
DEFAULT_ENCTYPE_DESERIALIZERS, | |||
deserializeFormObjectBody, | |||
EncTypeDeserializerMap, | |||
} from '../../utils/serialization'; | |||
import { METHOD_FORM_KEY, PREVENT_REDIRECT_FORM_KEY } from '../../common/constants'; | |||
import { ACTION_STATUS_CODE, DEFAULT_METHOD } from '../constants'; | |||
import { getBody } from '../../utils/request'; | |||
import { | |||
ENCTYPE_APPLICATION_OCTET_STREAM, | |||
ENCTYPE_MULTIPART_FORM_DATA, | |||
} from '../../common/enctypes'; | |||
import { ActionWrapperOptions } from './common'; | |||
import { IceformNextServerResponse } from '../response'; | |||
import { | |||
REQUEST_ID_COOKIE_KEY, | |||
CookieManager, | |||
} from '../../utils/cookies'; | |||
import { cacheResponse } from '../cache'; | |||
const getFormObjectMethodAndBody = async ( | |||
req: IncomingMessage, | |||
deserializers: EncTypeDeserializerMap, | |||
) => { | |||
const deserialized = await deserializeFormObjectBody({ | |||
req, | |||
deserializers, | |||
}); | |||
const { | |||
[METHOD_FORM_KEY]: method = req.method ?? DEFAULT_METHOD, | |||
...body | |||
} = deserialized as { | |||
[METHOD_FORM_KEY]?: string, | |||
[key: string]: unknown, | |||
}; | |||
return { | |||
body, | |||
method, | |||
}; | |||
}; | |||
const getBinaryMethodAndBody = async ( | |||
req: IncomingMessage, | |||
) => { | |||
const body = await getBody(req); | |||
return { | |||
body, | |||
method: req.method ?? DEFAULT_METHOD, | |||
}; | |||
}; | |||
const isContentTypeFormObject = ( | |||
contentType: string, | |||
deserializers: EncTypeDeserializerMap, | |||
) => ( | |||
contentType?.startsWith(`${ENCTYPE_MULTIPART_FORM_DATA};`) | |||
|| Object.keys(deserializers).includes(contentType) | |||
); | |||
/** | |||
* Wraps the `getServerSideProps` function to support no-script. | |||
* @param options | |||
*/ | |||
export const getServerSideProps = (options: ActionWrapperOptions): GetServerSideProps => async ( | |||
ctx, | |||
) => { | |||
const { | |||
referer = '/', | |||
'content-type': contentType = ENCTYPE_APPLICATION_OCTET_STREAM, | |||
} = ctx.req.headers; | |||
const { deserializers = DEFAULT_ENCTYPE_DESERIALIZERS } = options; | |||
const methodAndBodyFn = ( | |||
isContentTypeFormObject(contentType, deserializers) | |||
? getFormObjectMethodAndBody | |||
: getBinaryMethodAndBody | |||
); | |||
const { body, method } = await methodAndBodyFn(ctx.req, deserializers); | |||
const req = { | |||
...ctx.req, | |||
body, | |||
query: { | |||
...ctx.query, | |||
...(ctx.params ?? {}), | |||
}, | |||
// ?: how to prevent malicious method spoofing? | |||
method, | |||
} as DefaultNextApiRequest; | |||
const res = new IceformNextServerResponse(ctx.req); | |||
await options.fn(req, res); | |||
const requestId = crypto.randomUUID(); | |||
const cookieManager = new CookieManager(ctx); | |||
cookieManager.setCookie(REQUEST_ID_COOKIE_KEY, requestId); | |||
await cacheResponse(requestId, res); | |||
if (typeof options.onAction === 'function') { | |||
const onActionResult = await options.onAction( | |||
req, | |||
res, | |||
ctx, | |||
); | |||
if (onActionResult) { | |||
return onActionResult; | |||
} | |||
} | |||
const preventRedirect = ( | |||
typeof req.body === 'object' | |||
&& req.body !== null | |||
&& PREVENT_REDIRECT_FORM_KEY in req.body | |||
); | |||
const redirectDestination = ( | |||
res.location | |||
&& typeof options.mapLocationToRedirectDestination === 'function' | |||
&& !preventRedirect | |||
) | |||
? options.mapLocationToRedirectDestination(referer, res.location) | |||
: referer; | |||
return { | |||
redirect: { | |||
destination: redirectDestination, | |||
statusCode: ACTION_STATUS_CODE, | |||
}, | |||
props: { | |||
query: ctx.query, | |||
body: res.data, | |||
}, | |||
}; | |||
}; |
@@ -0,0 +1,3 @@ | |||
export * from './api'; | |||
export * from './common'; | |||
export * from './gssp'; |
@@ -0,0 +1,48 @@ | |||
import { createWriteStream } from 'fs'; | |||
import { readFile, unlink } from 'fs/promises'; | |||
interface CacheableResponse { | |||
statusCode: number; | |||
statusMessage?: string; | |||
contentType?: string; | |||
data?: unknown; | |||
} | |||
const getFilePathFromRequestId = (requestId: string) => `${requestId}`; | |||
export const cacheResponse = async (requestId: string, res: CacheableResponse) => { | |||
const filePath = getFilePathFromRequestId(requestId); | |||
const cacheStream = createWriteStream(filePath, { encoding: 'utf-8' }); | |||
cacheStream.write(`${res.statusCode.toString()} ${res.statusMessage || ''}\n`); | |||
if (res.contentType) { | |||
cacheStream.write(`Content-Type: ${res.contentType}\n`); | |||
} | |||
if (res.data) { | |||
cacheStream.write('\n'); | |||
cacheStream.write(res.data as string); | |||
} | |||
return new Promise((resolve) => { | |||
cacheStream.close(resolve); | |||
}); | |||
}; | |||
export const retrieveCache = async (requestId: string) => { | |||
const filePath = getFilePathFromRequestId(requestId); | |||
const requestBuffer = await readFile(filePath, 'utf-8'); | |||
await unlink(filePath); | |||
const [statusLine, ...headersAndBody] = requestBuffer.split('\n'); | |||
const [statusCode, ...statusMessageWords] = statusLine.split(' '); | |||
const statusMessage = statusMessageWords.join(' '); | |||
const bodyStart = headersAndBody.findIndex((line) => line === ''); | |||
const headers = headersAndBody.slice(0, bodyStart); | |||
const body = headersAndBody.slice(bodyStart + 1).join('\n'); | |||
const contentTypeHeader = headers.find((header) => header.toLowerCase().startsWith('content-type:')); | |||
const contentType = contentTypeHeader?.split(':')[1].trim(); | |||
return { | |||
statusCode: parseInt(statusCode, 10), | |||
statusMessage, | |||
contentType, | |||
body, | |||
}; | |||
}; |
@@ -2,20 +2,12 @@ import { ParsedUrlQuery } from 'querystring'; | |||
import { GetServerSideProps, GetServerSidePropsContext, PreviewData } from 'next'; | |||
import { deserialize } from 'seroval'; | |||
import { NextApiRequest, NextApiResponse } from '../common/types'; | |||
import { | |||
DEFAULT_ENCODING, | |||
DEFAULT_METHOD, | |||
DEFAULT_RESPONSE_STATUS_CODE, | |||
} from './constants'; | |||
import { DEFAULT_ENCODING, DEFAULT_METHOD } from './constants'; | |||
import { getBody } from '../utils/request'; | |||
import { | |||
BODY_COOKIE_KEY, CONTENT_TYPE_COOKIE_KEY, | |||
CookieManager, | |||
STATUS_CODE_COOKIE_KEY, | |||
STATUS_MESSAGE_COOKIE_KEY, | |||
} from '../utils/cookies'; | |||
import { REQUEST_ID_COOKIE_KEY, CookieManager } from '../utils/cookies'; | |||
import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; | |||
import { METHODS_WITH_BODY } from '../common/constants'; | |||
import { retrieveCache } from './cache'; | |||
export type DestinationGetServerSideProps< | |||
Props extends Record<string, unknown> = Record<string, unknown>, | |||
@@ -48,24 +40,20 @@ export const getServerSideProps = ( | |||
req.body = body.toString(DEFAULT_ENCODING); | |||
} | |||
const cookieManager = new CookieManager(ctx); | |||
// TODO how to properly remove cookies without leftovers? | |||
if (cookieManager.hasCookie(STATUS_CODE_COOKIE_KEY)) { | |||
ctx.res.statusCode = Number( | |||
cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || DEFAULT_RESPONSE_STATUS_CODE, | |||
); | |||
cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); | |||
} | |||
if (cookieManager.hasCookie(STATUS_MESSAGE_COOKIE_KEY)) { | |||
ctx.res.statusMessage = cookieManager.getCookie(STATUS_MESSAGE_COOKIE_KEY) || ''; | |||
cookieManager.unsetCookie(STATUS_MESSAGE_COOKIE_KEY); | |||
} | |||
const res: NextApiResponse = {}; | |||
if (cookieManager.hasCookie(BODY_COOKIE_KEY)) { | |||
const resBody = cookieManager.getCookie(BODY_COOKIE_KEY); | |||
const contentType = cookieManager.getCookie(CONTENT_TYPE_COOKIE_KEY); | |||
const cookieManager = new CookieManager(ctx); | |||
const resRequestId = cookieManager.getCookie(REQUEST_ID_COOKIE_KEY); | |||
cookieManager.unsetCookie(REQUEST_ID_COOKIE_KEY); | |||
if (resRequestId) { | |||
const { | |||
statusCode, | |||
statusMessage, | |||
contentType, | |||
body: resBody, | |||
} = await retrieveCache(resRequestId); | |||
ctx.res.statusCode = statusCode; | |||
ctx.res.statusMessage = statusMessage; | |||
if (contentType === ENCTYPE_APPLICATION_JSON) { | |||
res.body = deserialize(resBody); | |||
} else if (contentType === ENCTYPE_APPLICATION_OCTET_STREAM) { | |||
@@ -75,8 +63,6 @@ export const getServerSideProps = ( | |||
c.warn('Could not parse response body, returning nothing'); | |||
res.body = null; | |||
} | |||
cookieManager.unsetCookie(BODY_COOKIE_KEY); | |||
cookieManager.unsetCookie(CONTENT_TYPE_COOKIE_KEY); | |||
} | |||
const gspResult = ( | |||
@@ -1,9 +1,13 @@ | |||
import { ServerResponse } from 'http'; | |||
import { NextApiResponse as DefaultNextApiResponse } from 'next/dist/shared/lib/utils'; | |||
import { serialize } from 'seroval'; | |||
import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; | |||
import { ACTION_STATUS_CODE, CONTENT_TYPE_HEADER_KEY, LOCATION_HEADER_KEY } from './constants'; | |||
import { | |||
ACTION_STATUS_CODE, CONTENT_TYPE_HEADER_KEY, LOCATION_HEADER_KEY, DEFAULT_RESPONSE_STATUS_CODE, | |||
} from './constants'; | |||
// for client-side | |||
class DummyServerResponse {} | |||
const EffectiveServerResponse = ServerResponse ?? DummyServerResponse; | |||
@@ -19,6 +23,11 @@ export class IceformNextServerResponse | |||
private readonly revalidateResponse = Promise.resolve(undefined); | |||
constructor(...args: ConstructorParameters<typeof EffectiveServerResponse>) { | |||
super(...args); | |||
this.statusCode = DEFAULT_RESPONSE_STATUS_CODE; | |||
} | |||
setHeader(name: string, value: number | string | readonly string[]): this { | |||
super.setHeader(name, value); | |||
@@ -38,9 +47,13 @@ export class IceformNextServerResponse | |||
return this as unknown as DefaultNextApiResponse; | |||
} | |||
private static serialize(body: unknown) { | |||
return serialize(body); | |||
} | |||
json(body: unknown): void { | |||
this.contentType = ENCTYPE_APPLICATION_JSON; | |||
this.data = serialize(body); | |||
this.data = IceformNextServerResponse.serialize(body); | |||
} | |||
revalidate(): Promise<void> { | |||
@@ -59,7 +72,7 @@ export class IceformNextServerResponse | |||
this.contentType = ENCTYPE_APPLICATION_OCTET_STREAM; | |||
} | |||
this.data = serialize(body); | |||
this.data = IceformNextServerResponse.serialize(body); | |||
} | |||
setDraftMode(): DefaultNextApiResponse { | |||
@@ -1,5 +1,6 @@ | |||
import { IncomingMessage, ServerResponse } from 'http'; | |||
import * as nookies from 'nookies'; | |||
import * as crypto from 'crypto'; | |||
const COMMON_COOKIE_CONFIG = { | |||
path: '/', | |||
@@ -13,11 +14,7 @@ const COMMON_SET_COOKIE_CONFIG = { | |||
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 const REQUEST_ID_COOKIE_KEY = 'b' as const; | |||
export class CookieManager { | |||
private readonly ctx: { req: IncomingMessage, res: ServerResponse<IncomingMessage> }; | |||
@@ -27,13 +24,17 @@ export class CookieManager { | |||
} | |||
private static generateCookieKey(key: string) { | |||
return `if${key}${Date.now()}`; | |||
const random = crypto.randomBytes(16).toString('hex'); | |||
return `if${key}${random}`; | |||
} | |||
setCookie(key: string, value: string) { | |||
// cleanup previous cookie | |||
this.unsetCookie(key); | |||
cookieKeys[key] = CookieManager.generateCookieKey(key); | |||
nookies.setCookie( | |||
this.ctx, | |||
cookieKeys[key] = CookieManager.generateCookieKey(key), | |||
cookieKeys[key], | |||
value, | |||
COMMON_SET_COOKIE_CONFIG, | |||
); | |||
@@ -0,0 +1,44 @@ | |||
interface AbstractFormDataItem<T> { | |||
value: T | null; | |||
} | |||
export interface FileItem extends AbstractFormDataItem<Buffer> { | |||
kind: 'file'; | |||
mimeType: string; | |||
filename: string; | |||
} | |||
export interface FieldItem extends AbstractFormDataItem<string> { | |||
kind: 'field'; | |||
} | |||
export type FormDataItem = FileItem | FieldItem; | |||
export type FormDataMap = Record<string, FormDataItem>; | |||
export const addFormObjectBodyHelpers = (deserialized: Record<string, unknown>): FormDataMap => ( | |||
Object.fromEntries( | |||
Object | |||
.entries(deserialized) | |||
.filter(([, value]) => typeof value !== 'undefined') | |||
.map(([key, value]) => [ | |||
key, | |||
{ | |||
kind: 'field', | |||
value: value?.toString() ?? null, | |||
}, | |||
] satisfies [string, FieldItem]), | |||
) | |||
); | |||
export const removeFormObjectBodyHelpers = (formObject: FormDataMap): Record<string, unknown> => ( | |||
Object.fromEntries( | |||
Object | |||
.entries(formObject) | |||
.filter(([, value]) => typeof value !== 'undefined') | |||
.map(([key, valueWithHelper]) => [ | |||
key, | |||
valueWithHelper.value, | |||
]), | |||
) | |||
); |
@@ -28,24 +28,26 @@ export const parseMultipartFormData = async ( | |||
bb.on('file', (name, file, info) => { | |||
const { | |||
filename, | |||
mimeType: mimetype, | |||
mimeType, | |||
} = info; | |||
let fileData = Buffer.from(''); | |||
let buffer = Buffer.from(''); | |||
file.on('data', (data) => { | |||
fileData = Buffer.concat([fileData, data]); | |||
buffer = Buffer.concat([buffer, data]); | |||
}); | |||
file.on('close', () => { | |||
const newFile = fileData.buffer as unknown as Record<string, unknown>; | |||
newFile.name = filename; | |||
newFile.type = mimetype; | |||
body[name] = newFile; | |||
const bufferMut = buffer as unknown as Record<string, unknown>; | |||
bufferMut.name = filename; | |||
bufferMut.type = mimeType; | |||
bufferMut.size = buffer.length; | |||
body[name] = bufferMut; | |||
}); | |||
}); | |||
bb.on('field', (name, value) => { | |||
// TODO max length for long values, convert to reference instead | |||
body[name] = value; | |||
}); | |||
@@ -7,6 +7,7 @@ import { | |||
} from '../common/enctypes'; | |||
import { getBody, parseMultipartFormData } from './request'; | |||
import { METHOD_FORM_KEY, PREVENT_REDIRECT_FORM_KEY } from '../common/constants'; | |||
import { DEFAULT_ENCODING } from '../server/constants'; | |||
export type EncTypeSerializer = (data: unknown) => string; | |||
@@ -43,9 +44,16 @@ export const serializeBody = (params: SerializeBodyParams) => { | |||
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): FormData; | |||
new(formElement?: HTMLElement, submitter?: HTMLElement): FormData & { | |||
entries: () => IterableIterator<[string, unknown]>; | |||
}; | |||
}; | |||
const formData = new FormDataUnknown(form, options?.submitter); | |||
const emptyFiles = Array.from(formData.entries()) | |||
.filter(([, value]) => ( | |||
value instanceof File && value.size === 0 && value.name === '' | |||
)); | |||
emptyFiles.forEach(([key]) => formData.delete(key)); | |||
formData.delete(METHOD_FORM_KEY); | |||
formData.delete(PREVENT_REDIRECT_FORM_KEY); | |||
return formData; | |||
@@ -75,9 +83,12 @@ export interface DeserializeBodyParams { | |||
export const DEFAULT_ENCTYPE_DESERIALIZERS: EncTypeDeserializerMap = { | |||
[ENCTYPE_APPLICATION_JSON]: (data: string) => JSON.parse(data) as Record<string, unknown>, | |||
[ENCTYPE_X_WWW_FORM_URLENCODED]: (data: string) => Object.fromEntries( | |||
new URLSearchParams(data).entries(), | |||
), | |||
}; | |||
export const deserializeBody = async (params: DeserializeBodyParams): Promise<unknown> => { | |||
export const deserializeFormObjectBody = async (params: DeserializeBodyParams) => { | |||
const { req, deserializers = DEFAULT_ENCTYPE_DESERIALIZERS } = params; | |||
const contentType = req.headers['content-type'] ?? ENCTYPE_APPLICATION_OCTET_STREAM; | |||
@@ -85,18 +96,16 @@ export const deserializeBody = async (params: DeserializeBodyParams): Promise<un | |||
return parseMultipartFormData(req); | |||
} | |||
const encoding = (req.headers['content-encoding'] ?? 'utf-8') as BufferEncoding; | |||
const bodyRaw = await getBody(req); | |||
const encoding = ( | |||
req.headers['content-encoding'] ?? DEFAULT_ENCODING | |||
) as BufferEncoding; | |||
if (contentType === ENCTYPE_X_WWW_FORM_URLENCODED) { | |||
return Object.fromEntries( | |||
new URLSearchParams(bodyRaw.toString(encoding)).entries(), | |||
); | |||
} | |||
const { [contentType]: theDeserializer } = deserializers; | |||
if (typeof deserializers[contentType] === 'function') { | |||
return deserializers[contentType](bodyRaw.toString(encoding)); | |||
if (typeof theDeserializer !== 'function') { | |||
throw new Error(`Could not deserialize body with content type: ${contentType}`); | |||
} | |||
return bodyRaw.toString('binary'); | |||
return theDeserializer(bodyRaw.toString(encoding)) as Record<string, unknown>; | |||
}; |
@@ -11,6 +11,9 @@ importers: | |||
'@theoryofnekomata/formxtra': | |||
specifier: ^1.0.3 | |||
version: 1.0.3 | |||
'@web-std/file': | |||
specifier: ^3.0.3 | |||
version: 3.0.3 | |||
busboy: | |||
specifier: ^1.6.0 | |||
version: 1.6.0 | |||
@@ -18,8 +21,8 @@ importers: | |||
specifier: ^2.5.2 | |||
version: 2.5.2 | |||
seroval: | |||
specifier: ^0.9.0 | |||
version: 0.9.0 | |||
specifier: ^0.10.2 | |||
version: 0.10.2 | |||
devDependencies: | |||
'@testing-library/jest-dom': | |||
specifier: ^5.16.5 | |||
@@ -1306,6 +1309,7 @@ packages: | |||
typescript: 4.9.5 | |||
transitivePeerDependencies: | |||
- supports-color | |||
dev: true | |||
/@typescript-eslint/parser@6.7.0(eslint@8.49.0)(typescript@5.2.2): | |||
resolution: {integrity: sha512-jZKYwqNpNm5kzPVP5z1JXAuxjtl2uG+5NpaMocFPTNC2EdYIgbXIPImObOkhbONxtFTTdoZstLZefbaK+wXZng==} | |||
@@ -1334,6 +1338,7 @@ packages: | |||
dependencies: | |||
'@typescript-eslint/types': 5.62.0 | |||
'@typescript-eslint/visitor-keys': 5.62.0 | |||
dev: true | |||
/@typescript-eslint/scope-manager@6.7.0: | |||
resolution: {integrity: sha512-lAT1Uau20lQyjoLUQ5FUMSX/dS07qux9rYd5FGzKz/Kf8W8ccuvMyldb8hadHdK/qOI7aikvQWqulnEq2nCEYA==} | |||
@@ -1366,6 +1371,7 @@ packages: | |||
/@typescript-eslint/types@5.62.0: | |||
resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} | |||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |||
dev: true | |||
/@typescript-eslint/types@6.7.0: | |||
resolution: {integrity: sha512-ihPfvOp7pOcN/ysoj0RpBPOx3HQTJTrIN8UZK+WFd3/iDeFHHqeyYxa4hQk4rMhsz9H9mXpR61IzwlBVGXtl9Q==} | |||
@@ -1391,6 +1397,7 @@ packages: | |||
typescript: 4.9.5 | |||
transitivePeerDependencies: | |||
- supports-color | |||
dev: true | |||
/@typescript-eslint/typescript-estree@6.7.0(typescript@5.2.2): | |||
resolution: {integrity: sha512-dPvkXj3n6e9yd/0LfojNU8VMUGHWiLuBZvbM6V6QYD+2qxqInE7J+J/ieY2iGwR9ivf/R/haWGkIj04WVUeiSQ==} | |||
@@ -1439,6 +1446,7 @@ packages: | |||
dependencies: | |||
'@typescript-eslint/types': 5.62.0 | |||
eslint-visitor-keys: 3.4.3 | |||
dev: true | |||
/@typescript-eslint/visitor-keys@6.7.0: | |||
resolution: {integrity: sha512-/C1RVgKFDmGMcVGeD8HjKv2bd72oI1KxQDeY8uc66gw9R0OK0eMq48cA+jv9/2Ag6cdrsUGySm1yzYmfz0hxwQ==} | |||
@@ -1507,6 +1515,31 @@ packages: | |||
pretty-format: 29.7.0 | |||
dev: true | |||
/@web-std/blob@3.0.5: | |||
resolution: {integrity: sha512-Lm03qr0eT3PoLBuhkvFBLf0EFkAsNz/G/AYCzpOdi483aFaVX86b4iQs0OHhzHJfN5C15q17UtDbyABjlzM96A==} | |||
dependencies: | |||
'@web-std/stream': 1.0.0 | |||
web-encoding: 1.1.5 | |||
dev: false | |||
/@web-std/file@3.0.3: | |||
resolution: {integrity: sha512-X7YYyvEERBbaDfJeC9lBKC5Q5lIEWYCP1SNftJNwNH/VbFhdHm+3neKOQP+kWEYJmosbDFq+NEUG7+XIvet/Jw==} | |||
dependencies: | |||
'@web-std/blob': 3.0.5 | |||
dev: false | |||
/@web-std/stream@1.0.0: | |||
resolution: {integrity: sha512-jyIbdVl+0ZJyKGTV0Ohb9E6UnxP+t7ZzX4Do3AHjZKxUXKMs9EmqnBDQgHF7bEw0EzbQygOjtt/7gvtmi//iCQ==} | |||
dependencies: | |||
web-streams-polyfill: 3.2.1 | |||
dev: false | |||
/@zxing/text-encoding@0.9.0: | |||
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} | |||
requiresBuild: true | |||
dev: false | |||
optional: true | |||
/abab@2.0.6: | |||
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} | |||
dev: true | |||
@@ -2677,6 +2710,7 @@ packages: | |||
- eslint-import-resolver-node | |||
- eslint-import-resolver-webpack | |||
- supports-color | |||
dev: true | |||
/eslint-import-resolver-typescript@3.6.0(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.49.0): | |||
resolution: {integrity: sha512-QTHR9ddNnn35RTxlaEnx2gCxqFlF2SEN0SE2d17SqwyM7YOSI2GHWRYp5BiRkObTUNYPupC/3Fq2a0PpT+EKpg==} | |||
@@ -2729,6 +2763,7 @@ packages: | |||
eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.49.0) | |||
transitivePeerDependencies: | |||
- supports-color | |||
dev: true | |||
/eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0): | |||
resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} | |||
@@ -2809,6 +2844,7 @@ packages: | |||
- eslint-import-resolver-typescript | |||
- eslint-import-resolver-webpack | |||
- supports-color | |||
dev: true | |||
/eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0): | |||
resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} | |||
@@ -2829,7 +2865,7 @@ packages: | |||
doctrine: 2.1.0 | |||
eslint: 8.49.0 | |||
eslint-import-resolver-node: 0.3.9 | |||
eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0) | |||
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.49.0) | |||
has: 1.0.3 | |||
is-core-module: 2.13.0 | |||
is-glob: 4.0.3 | |||
@@ -3542,7 +3578,6 @@ packages: | |||
dependencies: | |||
call-bind: 1.0.2 | |||
has-tostringtag: 1.0.0 | |||
dev: true | |||
/is-array-buffer@3.0.2: | |||
resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} | |||
@@ -4892,8 +4927,8 @@ packages: | |||
- supports-color | |||
dev: true | |||
/seroval@0.9.0: | |||
resolution: {integrity: sha512-Ttr96/8czi3SXjbFFzpRc2Xpp1wvBufmaNuTviUL8eGQhUr1mdeiQ6YYSaLnMwMc4YWSeBggq72bKEBVu6/IFA==} | |||
/seroval@0.10.2: | |||
resolution: {integrity: sha512-aa9Tmthjs1wdSdtwr+USjeKHtML55ZJw2wPR6qjzsW9o+Og3eDNL+vJhjkIBH/pQT4bm4TUJ8+DcLlZq8RwzCg==} | |||
engines: {node: '>=10'} | |||
dev: false | |||
@@ -5278,6 +5313,7 @@ packages: | |||
/tslib@1.14.1: | |||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} | |||
dev: true | |||
/tslib@2.5.0: | |||
resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} | |||
@@ -5290,6 +5326,7 @@ packages: | |||
dependencies: | |||
tslib: 1.14.1 | |||
typescript: 4.9.5 | |||
dev: true | |||
/type-check@0.4.0: | |||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} | |||
@@ -5358,6 +5395,7 @@ packages: | |||
resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} | |||
engines: {node: '>=4.2.0'} | |||
hasBin: true | |||
dev: true | |||
/typescript@5.2.2: | |||
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} | |||
@@ -5419,6 +5457,16 @@ packages: | |||
/util-deprecate@1.0.2: | |||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} | |||
/util@0.12.5: | |||
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} | |||
dependencies: | |||
inherits: 2.0.4 | |||
is-arguments: 1.1.1 | |||
is-generator-function: 1.0.10 | |||
is-typed-array: 1.1.12 | |||
which-typed-array: 1.1.11 | |||
dev: false | |||
/utils-merge@1.0.1: | |||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} | |||
engines: {node: '>= 0.4.0'} | |||
@@ -5600,6 +5648,19 @@ packages: | |||
defaults: 1.0.4 | |||
dev: true | |||
/web-encoding@1.1.5: | |||
resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} | |||
dependencies: | |||
util: 0.12.5 | |||
optionalDependencies: | |||
'@zxing/text-encoding': 0.9.0 | |||
dev: false | |||
/web-streams-polyfill@3.2.1: | |||
resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} | |||
engines: {node: '>= 8'} | |||
dev: false | |||
/webidl-conversions@7.0.0: | |||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} | |||
engines: {node: '>=12'} | |||