Nuxt Nation conference is coming. Join us on November 12-13.

precognition
nuxt-precognition

Nuxt module implementing Laravel Precognition protocol in Nitro

Nuxt Precognition

npm versionnpm downloadsLicenseNuxt

This is a new version of nuxt-laravel-precognition. It offers same features, but being not dependent on Laravel.

Instead of supporting only $fetch and Laravel, it works with simple promises, targeting any backend that implements the base Precognition protocol. These promises will receive the form payload and protocol Headers.

Example

interface User = {
  email: string
  password: string
}

const form = useForm(
  (): User => ({ email: '', password: '' }),
  (body, headers) => $fetch('/api/login', { method: 'POST', headers, body })
)

This module comes with native Nitro integration, but will work with other backend as well.

Are you using only Lambda? You are covered will Lambda Precognition!!

It supports any validation library (who said Zod??) server or client side. You will need only to configure specific Error parsers.

Features

  •  Laravel compliant
  •  Validation library agnostic
  •  Client and server side validation
  •  Optimal Typescript support
  •  Highly customizable

How it works

Everything turns around errorParsers(user defined function to read validation errors from Error payload):

type ValidationErrors = Record<string, string | string[]>

interface ValidationErrorsData {
  message: string
  errors: ValidationErrors
}

type ValidationErrorParser = (error: Error) => ValidationErrorsData | undefined | null

You can define them globally (in Nuxt Plugin or custom eventHandler), or per form instance.

Imagine you are working with Zod.
Just create a nuxt plugin and define the "Zod error parser":

// plugins/precognition.ts

export default defineNuxtPlugin(() => {
  const { $precognition } = useNuxtApp()

  $precognition.errorParsers.push(
    (error) => {
      if (error instanceof ZodError) {
        const errors = {} as Record<string, string[]>
        error.errors.forEach((e) => {
          const key = e.path.join('.')
          if (key in errors) {
            errors[key].push(e.message)
            return
          }
          errors[key] = [e.message]
        })
        return { errors, message: 'Validation error' }
      }
      return null
    },
  )
})

From now on, everytime the useForm will catch the error, it will run our parses, and capture and assign any validation errors.

If you want to reuse the same options over multiple pages, you can create your custom composable by useForm.create factory function.

How about server side

Same idea, creating a nitro plugin:

// server/plugins/precognition.ts

import { ZodError } from 'zod'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', (event) => {
    event.context.$precognition.errorParsers = [
      (error) => {
        if (error instanceof ZodError) {
          const errors: Record<string, string[]> = {}
          error.errors.forEach((e) => {
            const key = e.path.join('.')
            if (key in errors) {
              errors[key].push(e.message)
              return
            }
            errors[key] = [e.message]
          })
          const message = error.errors.at(0)?.message ?? 'Validation error'
          return { errors, message }
        }
      },
    ]
  })
})

If you don't like hooking on every request, you can create your custom eventHandler by definePrecognitiveEventHandler.create factory function.

Make your validation logic inside the onRequest handler of the definePrecognitiveEventHandler.

// server/api/login.post.ts
import { z } from 'zod'
import { definePrecognitiveEventHandler, readBody } from '#imports'

const loginSchema = z.object({
  email: z.string().email().refine(_email => // Check for email uniqueness
    true, { message: 'Email is already in use' },
  ),
  password: z.string(),
}).refine((_data) => {
  // Check for email and password match
  // ...
  return true
},
{ message: 'invalid credentials', path: ['email'] },
)

export default definePrecognitiveEventHandler({
  async onRequest(event) {
    const body = await readBody(event)
    loginSchema.parse(body)
  },
  handler: () => {
    return {
      status: 200,
      body: {
        message: 'Success',
      },
    }
  },
})

This time the error will be converted to NuxtServerValidationError and captured client side, if we enable the predefined parsers in the nuxt configuration file:

// nuxt.config.ts

export default defineNuxtConfig({
  modules: ['nuxt-precognitiion'],
  precognition: {
    backendValidation: true,
    enableNuxtClientErrorParser: true,
  }
})

Remember to throw the ValidationError only in the onRequest handler (using the object notation).
Any logic in the base handler won't be process during precognitiveRequests.

  • Each event.context include also a flag ({ precognitive: boolean }), indicating if request is precognitive or not, looking at presence of Precognitive header.

Precognition Protocol

In case you need to define your own backend logic outside nitro (AWS Lamba), respect following list of requirements.

  • Precognitive Requests must have:
    1. Precognitive Header { 'Precognitive': 'true' }
  • To validate specific variables, each keys must be specified inside the ValidateOnly Header, comma separated and leveraging dot notation { 'Precognition-Validate-Only': 'name,age,address.street,address.number' }
  • To validate the full Form the ValidateOnly Header should be omitted or define as an empty string.
  • Successfull validation response must have:
    1. Precognitive Header { 'Precognitive': 'true' }
    2. Precognitive Successfull Header { 'Precognition-Success': 'true' }
    3. Precognitve Successfull status code: 204
  • Error validation response must have:
    1. Precognitive Header { 'Precognitive': 'true' }
    2. ValidationOnly header if needed { 'Precognition-Validate-Only': 'name,age,address.street,address.number' }
    3. Validation Error status code: 422
    4. Validation Errors and Message will be parsed as per your define logic, or using standard errorParsers:
      • NuxtErrorParsers: NuxtPrecognitiveErrorResponse: Response & { _data: { data: ValidationErrorsData }}
      • LaravelErrorParsers: LaravelPrecognitiveErrorResponse: Response & { _data: ValidationErrorsData }

Quick Setup

Install the module to your Nuxt application with one command:

npx nuxi module add nuxt-precognition

Configure

nametypedefaultdescription
validationTimeoutnumber1500Debounce time, in milliseconds, between two precognitive validation requests.
backendValidationbooleanfalseFlag to enable the precognitive validation.
validateFilesbooleanfalseFlag to enable files validation on precognitive requests.
enableNuxtClientErrorParserbooleanfalseFlag to enable nuxtErrorParsers @ client side (in form.validate and form.submit).
enableLaravelClientErrorParserbooleanfalseFlag to enable laravelErrorParsers @ client side (in form.validate and form.submit).
enableLaravelServerErrorParserbooleanfalseFlag to enable laravelErrorParsers @ client side (in definePrecognitiveEventHandler).

Status Handlers

Like in official package, you can define globally, or @instance level, custom handlers for specific error codes:

// plugins/precognition.ts

export default defineNuxtPlugin(() => {
  const { $precognition } = useNuxtApp()

  $precognition.statusHandlers = {
    401: async (error, form) => {
      form.error = createError('Unauthorized')
      await navigateTo('/login')
    },
    403: async (error, form) => {
      form.error = createError('Forbidden')
    },
  }
})

That's it! You can now use Nuxt Precognition in your Nuxt app ✨

Working with Laravel

  1. Define a plugin like this
// plugins/api.ts

export default defineNuxtPlugin((app) => {
  const { $precognition } = useNuxtApp()
  const token = useCookie('XSRF-TOKEN')

  const api = $fetch.create({
    baseURL: 'http://localhost',
    credentials: 'include',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    onRequest: ({ options }) => {
      // Setup csrf protection for every requests if available
      if (token.value) {
        const headers = new Headers(options.headers)
        headers.set('X-XSRF-TOKEN', token.value)
        options.headers = headers
      }
    },
    onResponse: (context) => {
      // ensure that all precognitive requests will receive precognitive responses
      $precognition.assertSuccessfulPrecognitiveResponses(context)
    },
  })

  async function fetchSanctumToken() {
    try {
      await api('/sanctum/csrf-cookie')
      token.value = useCookie('XSRF-TOKEN').value

      if (!token.value) {
        throw new Error('Failed to get CSRF token')
      }
    }
    catch (e) {
      console.error(e)
    }
  }

  app.hook('app:mounted', fetchSanctumToken)

  return {
    provide: {
      api,
      sanctum: {
        fetchToken: fetchSanctumToken,
        token,
      },
    },
  }
})
  1. Enable backend validation and native Laravel Error parsers client or server side
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-precognition'],
  precognition: {
    backendValidation: true,
    enableLaravelClientErrorParser: true,
  },
  /*
  ...
  */
})

* If you enableLaravelServerErrorParser, you must also enableNuxtClientErrorParser

  1. Setup Laravel Cors configuration file
// config/cors.php

return [

    /*
    |--------------------------------------------------------------------------
    | Cross-Origin Resource Sharing (CORS) Configuration
    |--------------------------------------------------------------------------
    |
    */

    'paths' => ['*'],

    'allowed_methods' => ['*'],

    'allowed_origins' => ['*'],

    'allowed_origins_patterns' => [env('FRONTEND_URL', 'http://localhost:3000')],

    'allowed_headers' => ['*'],

    'exposed_headers' => ['Precognition', 'Precognition-Success'],

    'max_age' => 0,

    'supports_credentials' => true,

];
  1. Enable the Precognition Middleware where needed
// routes/api.php

Route::middleware('precognitive')->group(function () {
    Route::apiResource('posts', \App\Http\Controllers\PostController::class);
});

Contribution

Local development
# Install dependencies
npm install

# Generate type stubs
npm run dev:prepare

# Develop with the playground
npm run dev

# Build the playground
npm run dev:build

# Run ESLint
npm run lint

# Run Vitest
npm run test
npm run test:watch

# Release new version
npm run release