Browse Source

Refactor code

Group related code in backend.
master
TheoryOfNekomata 7 months ago
parent
commit
2418947968
8 changed files with 285 additions and 221 deletions
  1. +6
    -3
      README.md
  2. +1
    -1
      packages/iceform-next-sandbox/src/pages/a/greet.ts
  3. +1
    -1
      packages/iceform-next-sandbox/src/pages/api/greet.ts
  4. +19
    -51
      packages/iceform-next/src/client.tsx
  5. +54
    -165
      packages/iceform-next/src/server.ts
  6. +60
    -0
      packages/iceform-next/src/utils/body.ts
  7. +55
    -0
      packages/iceform-next/src/utils/cookies.ts
  8. +89
    -0
      packages/iceform-next/src/utils/serialization.ts

+ 6
- 3
README.md View File

@@ -44,7 +44,7 @@ Then define the API route:
import * as Iceform from '@modal-sh/iceform-next';
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
export const config = Iceform.action.getApiConfig();
@@ -66,11 +66,11 @@ const ActionGreetPage: NextPage = () => null;

export default ActionGreetPage;

export const getServerSideProps = Iceform.action.getServerSideProps(greet);
export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet });
```

Lastly, define the form page:
```ts
```tsx
// [src/]pages/form.tsx

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)
- [ ] Tests
- [ ] Form with redirects
- [ ] Form with files
- [ ] Documentation
- [ ] Remix support

+ 1
- 1
packages/iceform-next-sandbox/src/pages/a/greet.ts View File

@@ -6,4 +6,4 @@ const ActionGreetPage: NextPage = () => null;

export default ActionGreetPage;

export const getServerSideProps = Iceform.action.getServerSideProps(greet);
export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet });

+ 1
- 1
packages/iceform-next-sandbox/src/pages/api/greet.ts View File

@@ -1,7 +1,7 @@
import * as Iceform from '@modal-sh/iceform-next';
import { greet } from '@/handlers/greet';

const handler = Iceform.action.wrapApiHandler(greet);
const handler = Iceform.action.wrapApiHandler({ fn: greet });

export const config = Iceform.action.getApiConfig();



+ 19
- 51
packages/iceform-next/src/client.tsx View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import { getFormValues } from '@theoryofnekomata/formxtra';
import fetchPonyfill from 'fetch-ponyfill';
import { NextPage as DefaultNextPage } from 'next';
import {
@@ -7,35 +6,41 @@ import {
NextApiRequest,
ENCTYPE_APPLICATION_JSON,
ENCTYPE_MULTIPART_FORM_DATA,
ENCTYPE_X_WWW_FORM_URLENCODED,
} from './common';
import {
DEFAULT_ENCTYPE_SERIALIZERS,
EncTypeSerializerMap, serializeBody,
SerializerOptions,
} from './utils/serialization';

const FormDerivedElementComponent = 'form' as const;

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;
method?: AllowedServerMethod;
clientAction?: string;
clientHeaders?: HeadersInit;
clientMethod?: BaseProps['method'];
clientMethod?: AllowedClientMethod;
invalidate?: (...args: unknown[]) => unknown;
refresh?: (response: Response) => void;
encTypeSerializers?: EncTypeSerializerMap;
responseEncType?: string;
serializerOptions?: GetFormValuesOptions;
serializerOptions?: SerializerOptions;
}

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>(({
children,
onSubmit,
@@ -151,10 +122,6 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({
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 serverMethod = serverMethodRaw === 'get' ? 'get' : 'post';

@@ -181,6 +148,7 @@ Form.defaultProps = {
clientMethod: undefined,
encTypeSerializers: DEFAULT_ENCTYPE_SERIALIZERS,
invalidate: undefined,
method: 'get' as const,
refresh: undefined,
responseEncType: ENCTYPE_APPLICATION_JSON,
serializerOptions: undefined,


+ 54
- 165
packages/iceform-next/src/server.ts View File

@@ -5,54 +5,22 @@ import {
NextApiResponse as DefaultNextApiResponse,
PageConfig,
} from 'next';
import * as nookies from 'nookies';
import { IncomingMessage } from 'http';
import busboy from 'busboy';
import { deserialize, serialize } from 'seroval';
import {
NextApiResponse,
NextApiRequest,
ENCTYPE_APPLICATION_JSON,
ENCTYPE_X_WWW_FORM_URLENCODED,
ENCTYPE_MULTIPART_FORM_DATA, ENCTYPE_APPLICATION_OCTET_STREAM,
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()}`;
};

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 const getServerSideProps = (
@@ -69,44 +37,33 @@ export namespace destination {
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?
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);
} else if (cookies[CONTENT_TYPE_COOKIE_KEY] === ENCTYPE_APPLICATION_OCTET_STREAM) {
} else if (contentType === ENCTYPE_APPLICATION_OCTET_STREAM) {
res.body = deserialize(resBody);
} else {
const c = console;
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;
@@ -134,68 +91,6 @@ export namespace destination {
}

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) => ({
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>;
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 mockReq = {
...ctx.req,
body: await deserializeBody(ctx.req),
body: await deserializeBody({
req: ctx.req,
deserializers: options.deserializers,
}),
} as DefaultNextApiRequest;

let data: unknown = null;
@@ -230,7 +140,8 @@ export namespace action {
return mockRes;
},
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) {
return;
}
@@ -247,37 +158,15 @@ export namespace action {
},
} 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) {
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) {
generateContentTypeCookieKey();
nookies.setCookie(ctx, CONTENT_TYPE_COOKIE_KEY, contentType, {
maxAge: 30 * 24 * 60 * 60,
path: '/',
httpOnly: true,
});
cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, contentType);
}
}



+ 60
- 0
packages/iceform-next/src/utils/body.ts View File

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

+ 55
- 0
packages/iceform-next/src/utils/cookies.ts View File

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

+ 89
- 0
packages/iceform-next/src/utils/serialization.ts View File

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

Loading…
Cancel
Save