Parcourir la source

Implement seroval

De/serialize response using seroval for cookies.
master
TheoryOfNekomata il y a 1 an
Parent
révision
6b99ea6236
7 fichiers modifiés avec 199 ajouts et 121 suppressions
  1. +0
    -17
      packages/iceform-next-sandbox/src/utils/body.ts
  2. +9
    -0
      packages/iceform-next/.eslintrc
  3. +2
    -1
      packages/iceform-next/package.json
  4. +53
    -27
      packages/iceform-next/src/client.tsx
  5. +12
    -5
      packages/iceform-next/src/common.ts
  6. +115
    -71
      packages/iceform-next/src/server.ts
  7. +8
    -0
      pnpm-lock.yaml

+ 0
- 17
packages/iceform-next-sandbox/src/utils/body.ts Voir le fichier

@@ -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);
});
});

+ 9
- 0
packages/iceform-next/.eslintrc Voir le fichier

@@ -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"
}


+ 2
- 1
packages/iceform-next/package.json Voir le fichier

@@ -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",


+ 53
- 27
packages/iceform-next/src/client.tsx Voir le fichier

@@ -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;


+ 12
- 5
packages/iceform-next/src/common.ts Voir le fichier

@@ -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;

+ 115
- 71
packages/iceform-next/src/server.ts Voir le fichier

@@ -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 {


+ 8
- 0
pnpm-lock.yaml Voir le fichier

@@ -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'}


Chargement…
Annuler
Enregistrer