@@ -0,0 +1,8 @@ | |||
root = true | |||
[*] | |||
charset = utf-8 | |||
indent_style = tab | |||
indent_size = tab | |||
tab_width = 2 | |||
end_of_line = lf |
@@ -0,0 +1,2 @@ | |||
.idea/ | |||
node_modules/ |
@@ -0,0 +1,24 @@ | |||
# `iceform` | |||
Use forms with or without client-side JavaScript--no code duplication required! | |||
Powered by [formxtra](https://code.modal.sh/modal-soft/formxtra). | |||
## Why? | |||
* Remove dependence from client-side JavaScript (graceful degradataion) | |||
* Allow scriptability of forms | |||
* Improved accessibility | |||
## Features | |||
* Emulate client-side behavior in a purely server-side manner | |||
* HTTP compliant (respects status code semantics) | |||
* Easy setup (adapts to how receiving requests are made in Next) | |||
## TODO | |||
* Content negotiation (custom request data) | |||
* Tests | |||
* Remix support |
@@ -0,0 +1,3 @@ | |||
{ | |||
"extends": "next/core-web-vitals" | |||
} |
@@ -0,0 +1,35 @@ | |||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||
# dependencies | |||
/node_modules | |||
/.pnp | |||
.pnp.js | |||
# testing | |||
/coverage | |||
# next.js | |||
/.next/ | |||
/out/ | |||
# production | |||
/build | |||
# misc | |||
.DS_Store | |||
*.pem | |||
# debug | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
# local env files | |||
.env*.local | |||
# vercel | |||
.vercel | |||
# typescript | |||
*.tsbuildinfo | |||
next-env.d.ts |
@@ -0,0 +1,38 @@ | |||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). | |||
## Getting Started | |||
First, run the development server: | |||
```bash | |||
npm run dev | |||
# or | |||
yarn dev | |||
# or | |||
pnpm dev | |||
``` | |||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | |||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. | |||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. | |||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. | |||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. | |||
## Learn More | |||
To learn more about Next.js, take a look at the following resources: | |||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | |||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. | |||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! | |||
## Deploy on Vercel | |||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. |
@@ -0,0 +1,6 @@ | |||
/** @type {import('next').NextConfig} */ | |||
const nextConfig = { | |||
reactStrictMode: true, | |||
} | |||
module.exports = nextConfig |
@@ -0,0 +1,27 @@ | |||
{ | |||
"name": "@modal-sh/iceform-next-sandbox", | |||
"version": "0.1.0", | |||
"private": true, | |||
"scripts": { | |||
"dev": "next dev", | |||
"build": "next build", | |||
"start": "next start", | |||
"lint": "next lint" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/iceform-next": "workspace:*", | |||
"@types/node": "20.6.1", | |||
"@types/react": "18.2.21", | |||
"@types/react-dom": "18.2.7", | |||
"autoprefixer": "10.4.15", | |||
"eslint": "8.49.0", | |||
"eslint-config-next": "13.4.19", | |||
"next": "13.4.19", | |||
"nookies": "^2.5.2", | |||
"postcss": "8.4.29", | |||
"react": "18.2.0", | |||
"react-dom": "18.2.0", | |||
"tailwindcss": "3.3.3", | |||
"typescript": "5.2.2" | |||
} | |||
} |
@@ -0,0 +1,6 @@ | |||
module.exports = { | |||
plugins: { | |||
tailwindcss: {}, | |||
autoprefixer: {}, | |||
}, | |||
} |
@@ -0,0 +1 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> |
@@ -0,0 +1 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg> |
@@ -0,0 +1,8 @@ | |||
import {NextApiHandler} from 'next'; | |||
export const greet: NextApiHandler = async (req, res) => { | |||
const { name } = req.body; | |||
res.status(202).json({ | |||
name: `Hello ${name}`, | |||
}); | |||
}; |
@@ -0,0 +1,6 @@ | |||
import '@/styles/globals.css' | |||
import type { AppProps } from 'next/app' | |||
export default function App({ Component, pageProps }: AppProps) { | |||
return <Component {...pageProps} /> | |||
} |
@@ -0,0 +1,13 @@ | |||
import { Html, Head, Main, NextScript } from 'next/document' | |||
export default function Document() { | |||
return ( | |||
<Html lang="en"> | |||
<Head /> | |||
<body> | |||
<Main /> | |||
<NextScript /> | |||
</body> | |||
</Html> | |||
) | |||
} |
@@ -0,0 +1,9 @@ | |||
import {NextPage} from 'next'; | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
import {greet} from '@/handlers/greet'; | |||
const ActionGreetPage: NextPage = () => null; | |||
export default ActionGreetPage; | |||
export const getServerSideProps = Iceform.action.getServerSideProps(greet); |
@@ -0,0 +1,8 @@ | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
import { greet } from '@/handlers/greet'; | |||
const handler = Iceform.action.wrapApiHandler(greet); | |||
export const config = Iceform.action.getApiConfig(); | |||
export default handler; |
@@ -0,0 +1,70 @@ | |||
import * as React from 'react'; | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
const GreetPage: Iceform.NextPage = ({ | |||
req, | |||
res, | |||
}) => { | |||
const {response, ...isoformProps} = Iceform.useResponse(res); | |||
const [responseData, setResponseData] = React.useState<unknown>(); | |||
React.useEffect(() => { | |||
response?.json().then((responseData) => { | |||
setResponseData(responseData); | |||
}); | |||
}, [response]); | |||
return ( | |||
<div> | |||
<h1>Iceform</h1> | |||
<h2> | |||
Request | |||
</h2> | |||
<h3>Query</h3> | |||
<pre> | |||
{JSON.stringify(req.query)} | |||
</pre> | |||
{typeof req.body !== 'undefined' && ( | |||
<> | |||
<h3>Body</h3> | |||
<pre> | |||
{JSON.stringify(req.body)} | |||
</pre> | |||
</> | |||
)} | |||
<h2> | |||
Response | |||
</h2> | |||
{typeof res.body !== 'undefined' && ( | |||
<pre> | |||
{JSON.stringify(res.body)} | |||
</pre> | |||
)} | |||
{typeof responseData !== 'undefined' && ( | |||
<pre> | |||
{JSON.stringify(responseData)} | |||
</pre> | |||
)} | |||
<Iceform.Form | |||
{...isoformProps} | |||
method="post" | |||
action="/a/greet" | |||
clientAction="/api/greet" | |||
> | |||
<input | |||
type="text" | |||
name="name" | |||
/> | |||
<button | |||
type="submit" | |||
> | |||
Submit | |||
</button> | |||
</Iceform.Form> | |||
</div> | |||
); | |||
}; | |||
export const getServerSideProps = Iceform.destination.getServerSideProps(); | |||
export default GreetPage; |
@@ -0,0 +1,27 @@ | |||
@tailwind base; | |||
@tailwind components; | |||
@tailwind utilities; | |||
:root { | |||
--foreground-rgb: 0, 0, 0; | |||
--background-start-rgb: 214, 219, 220; | |||
--background-end-rgb: 255, 255, 255; | |||
} | |||
@media (prefers-color-scheme: dark) { | |||
:root { | |||
--foreground-rgb: 255, 255, 255; | |||
--background-start-rgb: 0, 0, 0; | |||
--background-end-rgb: 0, 0, 0; | |||
} | |||
} | |||
body { | |||
color: rgb(var(--foreground-rgb)); | |||
background: linear-gradient( | |||
to bottom, | |||
transparent, | |||
rgb(var(--background-end-rgb)) | |||
) | |||
rgb(var(--background-start-rgb)); | |||
} |
@@ -0,0 +1,17 @@ | |||
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); | |||
}); | |||
}); |
@@ -0,0 +1,20 @@ | |||
import type { Config } from 'tailwindcss' | |||
const config: Config = { | |||
content: [ | |||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}', | |||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}', | |||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}', | |||
], | |||
theme: { | |||
extend: { | |||
backgroundImage: { | |||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', | |||
'gradient-conic': | |||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', | |||
}, | |||
}, | |||
}, | |||
plugins: [], | |||
} | |||
export default config |
@@ -0,0 +1,22 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "es5", | |||
"lib": ["dom", "dom.iterable", "esnext"], | |||
"allowJs": true, | |||
"skipLibCheck": true, | |||
"strict": true, | |||
"noEmit": true, | |||
"esModuleInterop": true, | |||
"module": "esnext", | |||
"moduleResolution": "bundler", | |||
"resolveJsonModule": true, | |||
"isolatedModules": true, | |||
"jsx": "preserve", | |||
"incremental": true, | |||
"paths": { | |||
"@/*": ["./src/*"] | |||
} | |||
}, | |||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], | |||
"exclude": ["node_modules"] | |||
} |
@@ -0,0 +1,9 @@ | |||
{ | |||
"root": true, | |||
"extends": [ | |||
"lxsmnsyc/typescript/react" | |||
], | |||
"parserOptions": { | |||
"project": "./tsconfig.eslint.json" | |||
} | |||
} |
@@ -0,0 +1,107 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
lerna-debug.log* | |||
# Diagnostic reports (https://nodejs.org/api/report.html) | |||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||
# Runtime data | |||
pids | |||
*.pid | |||
*.seed | |||
*.pid.lock | |||
# Directory for instrumented libs generated by jscoverage/JSCover | |||
lib-cov | |||
# Coverage directory used by tools like istanbul | |||
coverage | |||
*.lcov | |||
# nyc test coverage | |||
.nyc_output | |||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) | |||
.grunt | |||
# Bower dependency directory (https://bower.io/) | |||
bower_components | |||
# node-waf configuration | |||
.lock-wscript | |||
# Compiled binary addons (https://nodejs.org/api/addons.html) | |||
build/Release | |||
# Dependency directories | |||
node_modules/ | |||
jspm_packages/ | |||
# TypeScript v1 declaration files | |||
typings/ | |||
# TypeScript cache | |||
*.tsbuildinfo | |||
# Optional npm cache directory | |||
.npm | |||
# Optional eslint cache | |||
.eslintcache | |||
# Microbundle cache | |||
.rpt2_cache/ | |||
.rts2_cache_cjs/ | |||
.rts2_cache_es/ | |||
.rts2_cache_umd/ | |||
# Optional REPL history | |||
.node_repl_history | |||
# Output of 'npm pack' | |||
*.tgz | |||
# Yarn Integrity file | |||
.yarn-integrity | |||
# dotenv environment variables file | |||
.env | |||
.env.production | |||
.env.development | |||
# parcel-bundler cache (https://parceljs.org/) | |||
.cache | |||
# Next.js build output | |||
.next | |||
# Nuxt.js build / generate output | |||
.nuxt | |||
dist | |||
# Gatsby files | |||
.cache/ | |||
# Comment in the public line in if your project uses Gatsby and *not* Next.js | |||
# https://nextjs.org/blog/next-9-1#public-directory-support | |||
# public | |||
# vuepress build output | |||
.vuepress/dist | |||
# Serverless directories | |||
.serverless/ | |||
# FuseBox cache | |||
.fusebox/ | |||
# DynamoDB Local files | |||
.dynamodb/ | |||
# TernJS port file | |||
.tern-port | |||
.npmrc |
@@ -0,0 +1,7 @@ | |||
MIT License Copyright (c) 2023 TheoryOfNekomata <allan.crisostomo@outlook.com> | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@@ -0,0 +1,89 @@ | |||
{ | |||
"name": "@modal-sh/iceform-next", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=12" | |||
}, | |||
"license": "MIT", | |||
"keywords": [ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@testing-library/jest-dom": "^5.16.5", | |||
"@testing-library/react": "^13.4.0", | |||
"@types/busboy": "^1.5.1", | |||
"@types/cookie": "^0.5.2", | |||
"@types/express": "^4.17.17", | |||
"@types/node": "^18.14.1", | |||
"@types/react": "^18.0.27", | |||
"eslint": "^8.35.0", | |||
"eslint-config-lxsmnsyc": "^0.5.0", | |||
"express": "^4.18.2", | |||
"jsdom": "^21.1.0", | |||
"next": "13.4.19", | |||
"pridepack": "2.4.4", | |||
"react": "^18.2.0", | |||
"react-dom": "^18.2.0", | |||
"react-test-renderer": "^18.2.0", | |||
"tslib": "^2.5.0", | |||
"typescript": "^4.9.5", | |||
"vitest": "^0.28.1" | |||
}, | |||
"peerDependencies": { | |||
"next": "13.4.19", | |||
"react": "^16.8 || ^17.0 || ^18.0", | |||
"react-dom": "^16.8 || ^17.0 || ^18.0" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"lint": "pridepack lint", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
}, | |||
"private": false, | |||
"description": "Simple isomorphic forms for Next.", | |||
"repository": { | |||
"url": "https://code.modal.sh/modal-soft/isoform", | |||
"type": "git" | |||
}, | |||
"homepage": "https://code.modal.sh/modal-soft/isoform", | |||
"bugs": { | |||
"url": "https://code.modal.sh/modal-soft/isoform/issues" | |||
}, | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "public" | |||
}, | |||
"dependencies": { | |||
"@theoryofnekomata/formxtra": "^1.0.3", | |||
"busboy": "^1.6.0", | |||
"fetch-ponyfill": "^7.1.0", | |||
"nookies": "^2.5.2" | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js", | |||
"exports": { | |||
".": { | |||
"development": { | |||
"require": "./dist/cjs/development/index.js", | |||
"import": "./dist/esm/development/index.js" | |||
}, | |||
"require": "./dist/cjs/production/index.js", | |||
"import": "./dist/esm/production/index.js", | |||
"types": "./dist/types/index.d.ts" | |||
} | |||
}, | |||
"typesVersions": { | |||
"*": {} | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
{ | |||
"target": "es2018" | |||
} |
@@ -0,0 +1,168 @@ | |||
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'; | |||
const FormDerivedElementComponent = 'form' as const; | |||
type FormDerivedElement = HTMLElementTagNameMap[typeof FormDerivedElementComponent]; | |||
type BaseProps = React.HTMLProps<FormDerivedElement>; | |||
type EncTypeSerializer = (data: unknown) => string; | |||
type EncTypeSerializerMap = Record<string, EncTypeSerializer>; | |||
const DEFAULT_ENCTYPE_SERIALIZERS: EncTypeSerializerMap = { | |||
'application/json': (data: unknown) => JSON.stringify(data), | |||
}; | |||
type GetFormValuesOptions = Parameters<typeof getFormValues>[1]; | |||
interface FormProps extends Omit<BaseProps, 'action'> { | |||
action?: string; | |||
clientAction?: string; | |||
clientHeaders?: HeadersInit; | |||
clientMethod?: BaseProps['method']; | |||
invalidate?: (...args: unknown[]) => unknown; | |||
refresh?: (response: Response) => void; | |||
encTypeSerializers?: EncTypeSerializerMap; | |||
responseEncType?: string; | |||
serializerOptions?: GetFormValuesOptions; | |||
} | |||
export const useResponse = (res: NextApiResponse) => { | |||
const [response, setResponse] = React.useState<Response | undefined>( | |||
res.body ? new Response(res.body as any) : undefined | |||
); | |||
const onStale = React.useCallback(() => { | |||
setResponse(undefined); | |||
}, []); | |||
const onFresh = React.useCallback((response: Response) => { | |||
setResponse(response); | |||
}, []); | |||
return React.useMemo(() => ({ | |||
response, | |||
refresh: onFresh, | |||
invalidate: onStale, | |||
}), [ | |||
response, | |||
]); | |||
}; | |||
interface SerializeBodyParams { | |||
form: HTMLFormElement, | |||
encType: string, | |||
serializers: EncTypeSerializerMap, | |||
options?: GetFormValuesOptions, | |||
} | |||
const serializeBody = (params: SerializeBodyParams) => { | |||
const { | |||
form, | |||
encType, | |||
serializers, | |||
options | |||
} = params; | |||
if (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; | |||
}; | |||
return new FormDataUnknown(form, options?.submitter); | |||
} | |||
if (encType === 'application/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, | |||
action, | |||
method = 'get', | |||
clientAction = action, | |||
clientMethod = method, | |||
clientHeaders, | |||
encType = 'multipart/form-data', | |||
invalidate, | |||
refresh, | |||
encTypeSerializers = DEFAULT_ENCTYPE_SERIALIZERS, | |||
responseEncType = 'application/json', | |||
serializerOptions, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const handleSubmit: React.FormEventHandler<FormDerivedElement> = async (event) => { | |||
event.preventDefault(); | |||
const nativeEvent = event.nativeEvent as unknown as { submitter?: HTMLElement }; | |||
if (clientAction) { | |||
invalidate?.(); | |||
const { fetch } = fetchPonyfill(); | |||
const headers: HeadersInit = { | |||
...(clientHeaders ?? {}), | |||
'Accept': responseEncType, | |||
}; | |||
if (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, { | |||
method: clientMethod.toUpperCase(), | |||
body: serializeBody({ | |||
form: event.currentTarget, | |||
encType, | |||
serializers: encTypeSerializers, | |||
options: { | |||
...serializerOptions, | |||
submitter: nativeEvent.submitter, | |||
}, | |||
}), | |||
headers, | |||
}); | |||
refresh?.(response); | |||
} | |||
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'; | |||
const serverEncType = 'multipart/form-data'; | |||
return ( | |||
<FormDerivedElementComponent | |||
{...etcProps} | |||
ref={forwardedRef} | |||
onSubmit={handleSubmit} | |||
action={action} | |||
method={serverMethod} | |||
encType={serverEncType} | |||
> | |||
{children} | |||
</FormDerivedElementComponent> | |||
); | |||
}); | |||
export type NextPage<T = {}, U = T> = DefaultNextPage< | |||
T & { | |||
res: NextApiResponse; | |||
req: NextApiRequest; | |||
}, U | |||
> |
@@ -0,0 +1,10 @@ | |||
import {ParsedUrlQuery} from 'querystring'; | |||
export interface NextApiRequest { | |||
query: ParsedUrlQuery; | |||
body?: unknown; | |||
} | |||
export interface NextApiResponse { | |||
body?: unknown; | |||
} |
@@ -0,0 +1,3 @@ | |||
export * from './common'; | |||
export * from './client'; | |||
export * from './server'; |
@@ -0,0 +1,251 @@ | |||
import { | |||
GetServerSideProps, | |||
NextApiHandler, | |||
NextApiRequest as DefaultNextApiRequest, | |||
NextApiResponse as DefaultNextApiResponse, PageConfig, | |||
} from 'next'; | |||
import * as nookies from 'nookies'; | |||
import {IncomingMessage} from 'http'; | |||
import busboy from 'busboy'; | |||
import {NextApiResponse, NextApiRequest} from './common'; | |||
let BODY_COOKIE_KEY : string; | |||
let STATUS_CODE_COOKIE_KEY: string; | |||
let STATUS_MESSAGE_COOKIE_KEY: string; | |||
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); | |||
}); | |||
}); | |||
export namespace destination { | |||
export const getServerSideProps = (gspFn?: GetServerSideProps): GetServerSideProps => async (ctx) => { | |||
const req: NextApiRequest = { | |||
query: ctx.query, | |||
}; | |||
const { method = 'GET' } = ctx.req; | |||
if (!['GET', 'HEAD'].includes(method.toUpperCase())) { | |||
const body = await getBody(ctx.req); | |||
req.body = body.toString('utf-8'); | |||
} | |||
const cookies = nookies.parseCookies(ctx); | |||
const res: NextApiResponse = {}; | |||
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: '/', | |||
}); | |||
} | |||
if (STATUS_MESSAGE_COOKIE_KEY in cookies) { | |||
ctx.res.statusMessage = cookies[STATUS_MESSAGE_COOKIE_KEY] || ''; | |||
nookies.destroyCookie(ctx, STATUS_MESSAGE_COOKIE_KEY, { | |||
path: '/', | |||
}); | |||
} | |||
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); | |||
} else { | |||
console.warn('Could not parse response body, returning nothing'); | |||
} | |||
nookies.destroyCookie(ctx, BODY_COOKIE_KEY, { | |||
path: '/', | |||
}); | |||
} | |||
let gspResult; | |||
if (gspFn) { | |||
gspResult = await gspFn(ctx) | |||
} else { | |||
gspResult = { | |||
props: {}, | |||
}; | |||
} | |||
if ('props' in gspResult) { | |||
return { | |||
...gspResult, | |||
props: { | |||
...gspResult.props, | |||
req, | |||
res, | |||
}, | |||
}; | |||
} | |||
// redirect/not found will be treated as default behavior | |||
return gspResult; | |||
}; | |||
} | |||
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; | |||
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']; | |||
// 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') { | |||
const bodyRaw = await getBody(req); | |||
return JSON.parse(bodyRaw.toString(encoding)); | |||
} | |||
if (contentType === 'application/x-www-form-urlencoded') { | |||
const bodyRaw = await getBody(req); | |||
return Object.fromEntries( | |||
new URLSearchParams(bodyRaw.toString(encoding)).entries() | |||
); | |||
} | |||
if (contentType?.startsWith('multipart/form-data;')) { | |||
return parseMultipartFormData(req); | |||
} | |||
const bodyRaw = await getBody(req); | |||
return bodyRaw.toString('binary'); | |||
}; | |||
export const getServerSideProps = (fn: NextApiHandler): GetServerSideProps => async (ctx) => { | |||
const { referer } = ctx.req.headers; | |||
const mockReq = { | |||
...ctx.req, | |||
body: await deserializeBody(ctx.req), | |||
} as DefaultNextApiRequest; | |||
let data = null; | |||
const mockRes = { | |||
// todo handle other nextapiresponse methods (e.g. setting headers, writeHead, etc.) | |||
statusMessage: '', | |||
statusCode: 200, | |||
status(code: number) { | |||
// should we mask error status code to Bad Gateway? | |||
this.statusCode = code; | |||
return mockRes; | |||
}, | |||
send: (raw: any) => { | |||
// todo: how to transfer binary response in a more compact way? | |||
data = `raw:${raw.toString('base64')}`; | |||
}, | |||
json: (raw: any) => { | |||
data = `json:${JSON.stringify(raw)}`; | |||
}, | |||
} as DefaultNextApiResponse; | |||
await fn(mockReq, mockRes); | |||
generateStatusCodeCookieKey(); | |||
nookies.setCookie(ctx, STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString(), { | |||
maxAge: 30 * 24 * 60 * 60, | |||
path: '/', | |||
}); | |||
generateStatusMessageCookieKey(); | |||
nookies.setCookie(ctx, STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage, { | |||
maxAge: 30 * 24 * 60 * 60, | |||
path: '/', | |||
}); | |||
if (data) { | |||
generateBodyCookieKey(); | |||
nookies.setCookie(ctx, BODY_COOKIE_KEY, data, { | |||
maxAge: 30 * 24 * 60 * 60, | |||
path: '/', | |||
}); | |||
} | |||
return { | |||
redirect: { | |||
destination: referer, | |||
statusCode: 307, | |||
}, | |||
props: { | |||
query: ctx.query, | |||
body: data, | |||
}, | |||
}; | |||
}; | |||
} |
@@ -0,0 +1,13 @@ | |||
import React from 'react'; | |||
import { render, screen } from '@testing-library/react'; | |||
import '@testing-library/jest-dom'; | |||
import Hello from '../src'; | |||
describe('Example', () => { | |||
it('should have the expected content', () => { | |||
const greeting = 'World'; | |||
render(<Hello greeting={greeting} />); | |||
expect(screen.getByText(`Hello ${greeting}`)).toBeInTheDocument(); | |||
}); | |||
}); |
@@ -0,0 +1,21 @@ | |||
{ | |||
"exclude": ["node_modules"], | |||
"include": ["src", "types", "test"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": ["DOM", "ESNext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||
"rootDir": "./", | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"moduleResolution": "node", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"target": "es2018" | |||
} | |||
} |
@@ -0,0 +1,21 @@ | |||
{ | |||
"exclude": ["node_modules"], | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": ["DOM", "ESNext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||
"rootDir": "./src", | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"moduleResolution": "node", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"target": "es2018" | |||
} | |||
} |
@@ -0,0 +1,8 @@ | |||
/// <reference types="vitest" /> | |||
export default ({ | |||
test: { | |||
global: true, | |||
environment: 'jsdom', | |||
}, | |||
}); |
@@ -0,0 +1,2 @@ | |||
packages: | |||
- "packages/**" |