Skip to content

Instantly share code, notes, and snippets.

@xuannghia
Last active September 17, 2024 09:36
Show Gist options
  • Save xuannghia/428f82eb27db235e0ec12f4b71d56c68 to your computer and use it in GitHub Desktop.
Save xuannghia/428f82eb27db235e0ec12f4b71d56c68 to your computer and use it in GitHub Desktop.
Next.js utils
/* 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)
}
}
/* 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>
}
}
}
/* 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('/')
}
'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