Last active
September 17, 2024 09:36
-
-
Save xuannghia/428f82eb27db235e0ec12f4b71d56c68 to your computer and use it in GitHub Desktop.
Next.js utils
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
import { z } from 'zod' | |
type allKeys<T> = T extends any ? keyof T : never | |
type FlattenedError<T, U = string> = { | |
formErrors: U[] | |
fieldErrors: { | |
[P in allKeys<T>]?: U[] | |
} | |
} | |
class ActionError<T> extends Error { | |
constructor( | |
message: string, | |
public errors?: FlattenedError<T, string>, | |
) { | |
super(message) | |
} | |
} | |
type CreateActionOptions<InputSchema extends z.ZodType, Output> = { | |
schema: InputSchema | |
handler: (options: { input: z.infer<NonNullable<InputSchema>> }) => Promise<Output> | |
} | |
type CreateActionOptionsWithoutSchema<Output> = { | |
handler: (options: object) => Promise<Output> | |
} | |
export function createAction<T extends z.ZodType, O>( | |
options: CreateActionOptions<T, O>, | |
): (input: z.infer<NonNullable<T>>) => Promise<O> | |
export function createAction<O>(options: CreateActionOptionsWithoutSchema<O>): () => Promise<O> | |
export function createAction(options: any) { | |
return async (input: any) => { | |
if (options.schema) { | |
const parsed = options.schema!.safeParse(input) | |
if (!parsed.success) { | |
throw new ActionError<typeof input>('Invalid input', parsed.error.flatten()) | |
} | |
return options.handler({ input: parsed.data } as any) | |
} | |
return options.handler({ input: undefined } as any) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
import type { z } from 'zod' | |
import { type SubmissionResult } from '@conform-to/react' | |
import { parseWithZod } from '@conform-to/zod' | |
type Result<T> = SubmissionResult & { | |
data?: T | |
} | |
type HandlerOptions<InputSchema extends z.ZodType> = { | |
input: z.infer<NonNullable<InputSchema>> | |
} | |
type CreateFormActionOptions<InputSchema extends z.ZodType, Output> = { | |
schema: InputSchema | |
handler: (options: HandlerOptions<InputSchema>) => Promise<Output> | |
} | |
export function createFormAction<T extends z.ZodType, O>(options: CreateFormActionOptions<T, O>) { | |
return async (prevState: unknown, form: FormData) => { | |
const submission = parseWithZod(form, { schema: options.schema }) | |
if (submission.status !== 'success') { | |
return submission.reply() as Result<undefined> | |
} | |
try { | |
const data = await options.handler({ input: submission.value }) | |
const reply = submission.reply() | |
return { ...reply, data } as Result<O> | |
} catch (error: any) { | |
// Throw errors that start with NEXT_ so Next.js can handle them (e.g. NEXT_REDIRECT, NEXT_NOT_FOUND,...) | |
if (error.message?.startsWith('NEXT_')) { | |
throw error | |
} | |
return submission.reply({ | |
formErrors: [error.message ? error.message : 'Internal server error'], | |
fieldErrors: error.errors, | |
}) as Result<undefined> | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
/** | |
* Ported from `react-router` | |
* @see https://github.com/remix-run/react-router/blob/4b1f73f3dc5af23a378554ae708105549c8ff82a/packages/router/utils.ts | |
*/ | |
function warning(cond: any, message: string) { | |
if (!cond) { | |
// eslint-disable-next-line no-console | |
if (typeof console !== 'undefined') console.warn(message) | |
try { | |
// Welcome to debugging history! | |
// | |
// This error is thrown as a convenience, so you can more easily | |
// find the source for a warning that appears in the console by | |
// enabling "pause on exceptions" in your JavaScript debugger. | |
throw new Error(message) | |
// eslint-disable-next-line no-empty | |
} catch (e) {} | |
} | |
} | |
/** | |
* @private | |
*/ | |
function invariant(value: boolean, message?: string): asserts value | |
function invariant<T>(value: T | null | undefined, message?: string): asserts value is T | |
function invariant(value: any, message?: string) { | |
if (value === false || value === null || typeof value === 'undefined') { | |
throw new Error(message) | |
} | |
} | |
// Recursive helper for finding path parameters in the absence of wildcards | |
type _PathParam<Path extends string> = | |
// split path into individual path segments | |
Path extends `${infer L}/${infer R}` | |
? _PathParam<L> | _PathParam<R> | |
: // find params after `:` | |
Path extends `:${infer Param}` | |
? Param extends `${infer Optional}?` | |
? Optional | |
: Param | |
: // otherwise, there aren't any params present | |
never | |
/** | |
* Examples: | |
* "/a/b/*" -> "*" | |
* ":a" -> "a" | |
* "/a/:b" -> "b" | |
* "/a/blahblahblah:b" -> "b" | |
* "/:a/:b" -> "a" | "b" | |
* "/:a/b/:c/*" -> "a" | "c" | "*" | |
*/ | |
export type PathParam<Path extends string> = | |
// check if path is just a wildcard | |
Path extends '*' | '/*' | |
? '*' | |
: // look for wildcard at the end of the path | |
Path extends `${infer Rest}/*` | |
? '*' | _PathParam<Rest> | |
: // look for params in the absence of wildcards | |
_PathParam<Path> | |
/** | |
* Returns a path with params interpolated. | |
* | |
* @see https://reactrouter.com/utils/generate-path | |
*/ | |
export function generatePath<Path extends string>( | |
originalPath: Path, | |
params: { | |
[key in PathParam<Path>]: string | null | |
} = {} as any, | |
): string { | |
let path: string = originalPath | |
if (path.endsWith('*') && path !== '*' && !path.endsWith('/*')) { | |
warning( | |
false, | |
`Route path "${path}" will be treated as if it were ` + | |
`"${path.replace(/\*$/, '/*')}" because the \`*\` character must ` + | |
`always follow a \`/\` in the pattern. To get rid of this warning, ` + | |
`please change the route path to "${path.replace(/\*$/, '/*')}".`, | |
) | |
path = path.replace(/\*$/, '/*') as Path | |
} | |
// ensure `/` is added at the beginning if the path is absolute | |
const prefix = path.startsWith('/') ? '/' : '' | |
const stringify = (p: any) => (p == null ? '' : typeof p === 'string' ? p : String(p)) | |
const segments = path | |
.split(/\/+/) | |
.map((segment, index, array) => { | |
const isLastSegment = index === array.length - 1 | |
// only apply the splat if it's the last segment | |
if (isLastSegment && segment === '*') { | |
const star = '*' as PathParam<Path> | |
// Apply the splat | |
return stringify(params[star]) | |
} | |
const keyMatch = segment.match(/^:([\w-]+)(\??)$/) | |
if (keyMatch) { | |
const [, key, optional] = keyMatch | |
const param = params[key as PathParam<Path>] | |
invariant(optional === '?' || param != null, `Missing ":${key}" param`) | |
return stringify(param) | |
} | |
// Remove any optional markers from optional static segments | |
return segment.replace(/\?$/g, '') | |
}) | |
// Remove empty segments | |
.filter((segment) => !!segment) | |
return prefix + segments.join('/') | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use server' | |
import type { PathParam } from '@/libs/generate-path' | |
import { generatePath } from '@/libs/generate-path' | |
import { getToken } from '@/modules/auth/get-session' | |
type ApiHandlerOptions<Path extends string> = { | |
path: Path | |
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | |
params?: { | |
[key in PathParam<Path>]: string | null | |
} | |
query?: Record<string, string | number | undefined | null> | |
headers?: Record<string, string> | |
body?: BodyInit | null | undefined | |
next?: NextFetchRequestConfig | undefined | |
cache?: RequestCache | |
} | |
export async function makeApiRequest<Result>(options: ApiHandlerOptions<string>) { | |
const { method = 'GET', path, params, query, body, headers, next, cache } = options | |
const generatedPath = params ? generatePath(path, params) : path | |
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}${generatedPath}` | |
let searchString = '' | |
if (query) { | |
const cleanQuery = Object.fromEntries( | |
Object.entries(query) | |
.filter(([, value]) => value !== undefined && value !== null) | |
.map(([key, value]) => [key, String(value)]), | |
) | |
searchString = `?${new URLSearchParams(cleanQuery).toString()}` | |
} | |
const token = await getToken() | |
const response = await fetch(`${url}${searchString}`, { | |
method, | |
headers: { | |
'Content-Type': 'application/json', | |
...(token ? { Authorization: `Bearer ${token}` } : {}), | |
...headers, | |
}, | |
body: body && method !== 'GET' ? body : undefined, | |
cache, | |
next, | |
}) | |
const data = await response.json() | |
if (!response.ok) { | |
throw data | |
} | |
return data as Result | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment