瀏覽代碼

Add binary support

Use filesystem-based cache to get response details instead of using the cookies (only use cookies for getting the request ID [FIXME]).
master
TheoryOfNekomata 7 月之前
父節點
當前提交
05d7d6bd3f
共有 21 個檔案被更改,包括 477 行新增231 行删除
  1. +3
    -2
      README.md
  2. +5
    -0
      packages/iceform-next-sandbox/next.config.js
  3. +5
    -2
      packages/iceform-next-sandbox/src/handlers/note.ts
  4. +4
    -0
      packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
  5. +18
    -7
      packages/iceform-next-sandbox/src/pages/notes/index.tsx
  6. +3
    -1
      packages/iceform-next/.eslintrc
  7. +6
    -5
      packages/iceform-next/package.json
  8. +1
    -1
      packages/iceform-next/pridepack.json
  9. +0
    -148
      packages/iceform-next/src/server/action.ts
  10. +34
    -0
      packages/iceform-next/src/server/action/api.ts
  11. +31
    -0
      packages/iceform-next/src/server/action/common.ts
  12. +135
    -0
      packages/iceform-next/src/server/action/gssp.ts
  13. +3
    -0
      packages/iceform-next/src/server/action/index.ts
  14. +48
    -0
      packages/iceform-next/src/server/cache.ts
  15. +16
    -30
      packages/iceform-next/src/server/destination.ts
  16. +16
    -3
      packages/iceform-next/src/server/response.ts
  17. +8
    -7
      packages/iceform-next/src/utils/cookies.ts
  18. +44
    -0
      packages/iceform-next/src/utils/helpers.ts
  19. +10
    -8
      packages/iceform-next/src/utils/request.ts
  20. +20
    -11
      packages/iceform-next/src/utils/serialization.ts
  21. +67
    -6
      pnpm-lock.yaml

+ 3
- 2
README.md 查看文件

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

+ 5
- 0
packages/iceform-next-sandbox/next.config.js 查看文件

@@ -4,6 +4,11 @@ const nextConfig = {
experimental: {
optimizeCss: true,
},
webpack: (config) => {
config.resolve.fallback = { fs: false };

return config;
},
};

module.exports = nextConfig;

+ 5
- 2
packages/iceform-next-sandbox/src/handlers/note.ts 查看文件

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



+ 4
- 0
packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx 查看文件

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


+ 18
- 7
packages/iceform-next-sandbox/src/pages/notes/index.tsx 查看文件

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


+ 3
- 1
packages/iceform-next/.eslintrc 查看文件

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


+ 6
- 5
packages/iceform-next/package.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
- 1
packages/iceform-next/pridepack.json 查看文件

@@ -1,3 +1,3 @@
{
"target": "es2018"
}
}

+ 0
- 148
packages/iceform-next/src/server/action.ts 查看文件

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

+ 34
- 0
packages/iceform-next/src/server/action/api.ts 查看文件

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

+ 31
- 0
packages/iceform-next/src/server/action/common.ts 查看文件

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

+ 135
- 0
packages/iceform-next/src/server/action/gssp.ts 查看文件

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

+ 3
- 0
packages/iceform-next/src/server/action/index.ts 查看文件

@@ -0,0 +1,3 @@
export * from './api';
export * from './common';
export * from './gssp';

+ 48
- 0
packages/iceform-next/src/server/cache.ts 查看文件

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

+ 16
- 30
packages/iceform-next/src/server/destination.ts 查看文件

@@ -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 = (


+ 16
- 3
packages/iceform-next/src/server/response.ts 查看文件

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


+ 8
- 7
packages/iceform-next/src/utils/cookies.ts 查看文件

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


+ 44
- 0
packages/iceform-next/src/utils/helpers.ts 查看文件

@@ -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,
]),
)
);

+ 10
- 8
packages/iceform-next/src/utils/request.ts 查看文件

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



+ 20
- 11
packages/iceform-next/src/utils/serialization.ts 查看文件

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

+ 67
- 6
pnpm-lock.yaml 查看文件

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


Loading…
取消
儲存