inertia-sails 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,6 +90,11 @@ Return a URL string to redirect:
90
90
  return '/dashboard'
91
91
  ```
92
92
 
93
+ `inertiaRedirect` performs an Inertia location visit. It does not return a
94
+ page object, so `sails.inertia.preserveFragment()` does not apply to this
95
+ response type. If you already know the fragment you want, include it in the
96
+ returned URL.
97
+
93
98
  #### Preserving URL fragments
94
99
 
95
100
  When a standard Inertia redirect should carry the current hash to the next
@@ -189,6 +194,154 @@ sails.inertia.flash({ error: 'Failed', field: 'email' })
189
194
 
190
195
  Access in your frontend via `page.props.flash`.
191
196
 
197
+ ### Error Pages
198
+
199
+ In development, 500-level HTML responses render a rich Youch error page with
200
+ the stack trace and sanitized request metadata. For Inertia visits, the client
201
+ will show that HTML in its development error modal.
202
+
203
+ In production, `inertia-sails` renders the configured Inertia error page for
204
+ `403`, `404`, `500`, and `503` responses by default. The page receives
205
+ `status`, `title`, and `message` props:
206
+
207
+ ```vue
208
+ <!-- assets/js/pages/error.vue -->
209
+ <script setup>
210
+ import { Head, Link } from '@inertiajs/vue3'
211
+
212
+ defineProps({
213
+ status: Number,
214
+ title: String,
215
+ message: String
216
+ })
217
+ </script>
218
+
219
+ <template>
220
+ <Head :title="`${status} ${title}`" />
221
+
222
+ <main>
223
+ <p>Status {{ status }}</p>
224
+ <h1>{{ title }}</h1>
225
+ <p>{{ message }}</p>
226
+ <Link href="/">Go home</Link>
227
+ </main>
228
+ </template>
229
+ ```
230
+
231
+ Wire Sails' standard responses through the hook so framework-level 403/404/500
232
+ responses use the same policy:
233
+
234
+ ```js
235
+ // api/responses/serverError.js
236
+ module.exports = function serverError(error) {
237
+ return this.req._sails.inertia.handleServerError(this.req, this.res, error)
238
+ }
239
+
240
+ // api/responses/notFound.js
241
+ module.exports = function notFound(error) {
242
+ return this.req._sails.inertia.handleErrorPage(this.req, this.res, {
243
+ statusCode: 404,
244
+ error
245
+ })
246
+ }
247
+
248
+ // api/responses/forbidden.js
249
+ module.exports = function forbidden(error) {
250
+ return this.req._sails.inertia.handleErrorPage(this.req, this.res, {
251
+ statusCode: 403,
252
+ error
253
+ })
254
+ }
255
+ ```
256
+
257
+ Hybrid apps can keep classic Sails EJS error views by disabling the Inertia
258
+ error page:
259
+
260
+ ```js
261
+ // config/inertia.js
262
+ module.exports.inertia = {
263
+ errorPage: false
264
+ }
265
+ ```
266
+
267
+ ### Precognition
268
+
269
+ Inertia v3 forms can validate against server-owned Sails rules before the real
270
+ submit runs. `inertia-sails` handles the Precognition response headers and
271
+ returns validation failures as `422` JSON instead of redirecting through the
272
+ normal session-backed Inertia error flow.
273
+
274
+ Use `withPrecognition()` on the client:
275
+
276
+ ```js
277
+ const form = useForm({
278
+ email: null
279
+ }).withPrecognition('post', '/forgot-password')
280
+ ```
281
+
282
+ Then validate a field when the user leaves it:
283
+
284
+ ```vue
285
+ <InputEmail v-model="form.email" @blur="form.validate('email')" />
286
+ ```
287
+
288
+ On the server, add a small custom response so successful Precognition checks
289
+ can exit before side effects without going through the action's normal
290
+ `success` response type:
291
+
292
+ ```js
293
+ // api/responses/precognitionSuccess.js
294
+ module.exports = function precognitionSuccess() {
295
+ return this.req._sails.inertia.handlePrecognitionSuccess(this.req, this.res)
296
+ }
297
+ ```
298
+
299
+ Then use a named exit in the action:
300
+
301
+ ```js
302
+ exits: {
303
+ success: {
304
+ responseType: 'redirect'
305
+ },
306
+ precognitionSuccess: {
307
+ responseType: 'precognitionSuccess'
308
+ }
309
+ },
310
+
311
+ fn: async function ({ email }, exits) {
312
+ if (sails.inertia.isPrecognitive(this.req)) {
313
+ return exits.precognitionSuccess()
314
+ }
315
+
316
+ await sendPasswordResetEmail(email)
317
+ return '/check-email'
318
+ }
319
+ ```
320
+
321
+ For custom database-backed checks, use `shouldValidate()` so expensive rules
322
+ only run when the client asked for that field:
323
+
324
+ ```js
325
+ if (sails.inertia.shouldValidate('email', this.req)) {
326
+ const existingUser = await User.findOne({ email })
327
+
328
+ if (existingUser) {
329
+ throw {
330
+ badSignupRequest: {
331
+ problems: [{ email: 'An account with this email already exists.' }]
332
+ }
333
+ }
334
+ }
335
+ }
336
+ ```
337
+
338
+ Available helpers:
339
+
340
+ - `sails.inertia.isPrecognitive(req?)`
341
+ - `sails.inertia.validateOnly(req?)`
342
+ - `sails.inertia.shouldValidate(field, req?)`
343
+ - `sails.inertia.handlePrecognitionSuccess(req, res)` for custom responses
344
+
192
345
  ### Deferred Props
193
346
 
194
347
  Load props after initial page render:
@@ -222,6 +375,22 @@ return {
222
375
  }
223
376
  ```
224
377
 
378
+ Or pass the rescue option inline:
379
+
380
+ ```js
381
+ return {
382
+ page: 'dashboard',
383
+ props: {
384
+ analytics: sails.inertia.defer(
385
+ async () => {
386
+ return await Analytics.getExpensiveReport()
387
+ },
388
+ { rescue: true }
389
+ )
390
+ }
391
+ }
392
+ ```
393
+
225
394
  When a rescued deferred prop throws, it is omitted from `props` and its key is
226
395
  reported in `rescuedProps`, allowing the client `<Deferred>` component to show
227
396
  its rescue slot instead of failing the whole deferred response.
@@ -381,6 +550,8 @@ Copy these to `api/responses/`:
381
550
  - **inertiaRedirect.js** - Handle Inertia redirects
382
551
  - **badRequest.js** - Validation errors with redirect back
383
552
  - **serverError.js** - Error modal in dev, graceful redirect in prod
553
+ - **notFound.js** - 404 status pages
554
+ - **forbidden.js** - 403 status pages
384
555
 
385
556
  ## Architecture
386
557
 
@@ -400,7 +571,12 @@ module.exports.inertia = {
400
571
  // History encryption settings
401
572
  history: {
402
573
  encrypt: false
403
- }
574
+ },
575
+
576
+ // Production status page component.
577
+ // Set to false to keep classic Sails EJS error views.
578
+ errorPage: 'error',
579
+ errorStatuses: [403, 404, 500, 503]
404
580
  }
405
581
  ```
406
582
 
package/index.js CHANGED
@@ -23,6 +23,7 @@
23
23
  * @property {string} page - The component name to render
24
24
  * @property {InertiaProps} [props] - Props to pass to the component
25
25
  * @property {InertiaProps} [locals] - Additional locals for the root EJS template
26
+ * @property {boolean} [ssr] - Whether to server-render this response when SSR is enabled
26
27
  *
27
28
  * @typedef {Object} DeferOptions
28
29
  * @property {boolean} [rescue=false] - Rescue callback failures
@@ -40,6 +41,12 @@ const inertia = require('./lib/middleware/inertia-middleware')
40
41
  const render = require('./lib/render')
41
42
  const location = require('./lib/location')
42
43
  const requestContext = require('./lib/helpers/request-context')
44
+ const {
45
+ getValidateOnlyFields,
46
+ isPrecognitiveRequest,
47
+ sendPrecognitionSuccess,
48
+ shouldValidateField
49
+ } = require('./lib/helpers/precognition')
43
50
 
44
51
  const DeferProp = require('./lib/props/defer-prop')
45
52
  const OptionalProp = require('./lib/props/optional-prop')
@@ -112,11 +119,19 @@ module.exports = function defineInertiaHook(sails) {
112
119
  defaults: {
113
120
  inertia: {
114
121
  rootView: 'app',
122
+ errorPage: 'error',
123
+ errorStatuses: [403, 404, 500, 503],
115
124
  // Auto-version from Shipwright manifest for cache busting
116
125
  // Override in config/inertia.js if needed
117
126
  version: () => getManifestVersion(),
118
127
  history: {
119
128
  encrypt: false
129
+ },
130
+ ssr: {
131
+ enabled: false,
132
+ bundle: '.tmp/ssr/inertia.mjs',
133
+ pages: false,
134
+ fallback: true
120
135
  }
121
136
  }
122
137
  },
@@ -639,6 +654,69 @@ module.exports = function defineInertiaHook(sails) {
639
654
  return handleBadRequest(req, res, optionalData)
640
655
  },
641
656
 
657
+ /**
658
+ * Determine if the current request is a Precognition validation request.
659
+ * @param {Request} [req] - The request object. Defaults to request context.
660
+ * @returns {boolean} - Whether this is a Precognition request.
661
+ */
662
+ isPrecognitive(req) {
663
+ return isPrecognitiveRequest(req || requestContext.getRequest())
664
+ },
665
+
666
+ /**
667
+ * Get the fields requested by Precognition-Validate-Only.
668
+ * @param {Request} [req] - The request object. Defaults to request context.
669
+ * @returns {string[]} - Fields requested for validation.
670
+ */
671
+ validateOnly(req) {
672
+ return getValidateOnlyFields(req || requestContext.getRequest())
673
+ },
674
+
675
+ /**
676
+ * Determine if a field should run validation for the current request.
677
+ * For non-Precognition requests, all fields should validate.
678
+ * @param {string} field - Field name, with dot notation and * wildcards supported.
679
+ * @param {Request} [req] - The request object. Defaults to request context.
680
+ * @returns {boolean} - Whether to validate this field.
681
+ */
682
+ shouldValidate(field, req) {
683
+ return shouldValidateField(req || requestContext.getRequest(), field)
684
+ },
685
+
686
+ /**
687
+ * Handle a successful Precognition validation response.
688
+ * This is intended for app-level custom responses such as
689
+ * `api/responses/precognitionSuccess.js`.
690
+ * @param {Request} req - The request object
691
+ * @param {Response} res - The response object
692
+ * @returns {*} - A 204 Precognition response.
693
+ */
694
+ handlePrecognitionSuccess(req, res) {
695
+ if (!res) {
696
+ throw new Error(
697
+ 'sails.inertia.handlePrecognitionSuccess() called without a response object'
698
+ )
699
+ }
700
+
701
+ return sendPrecognitionSuccess(res)
702
+ },
703
+
704
+ /**
705
+ * Send a successful Precognition validation response.
706
+ * Low-level alias for custom responses and non-action callers.
707
+ * In actions with a responseType, prefer a named exit using a
708
+ * `precognitionSuccess` response file.
709
+ * @param {Request} [req] - The request object. Defaults to request context.
710
+ * @param {Response} [res] - The response object. Defaults to request context.
711
+ * @returns {*} - A 204 Precognition response.
712
+ */
713
+ precognitionSuccess(req, res) {
714
+ return this.handlePrecognitionSuccess(
715
+ req || requestContext.getRequest(),
716
+ res || requestContext.getResponse()
717
+ )
718
+ },
719
+
642
720
  /**
643
721
  * Handle server error responses for Inertia.js
644
722
  * For Inertia requests in development, displays a styled error modal with stack trace.
@@ -653,6 +731,20 @@ module.exports = function defineInertiaHook(sails) {
653
731
  return handleServerError(req, res, error)
654
732
  },
655
733
 
734
+ /**
735
+ * Handle application status/error pages for Inertia.js.
736
+ * In development, 500-level HTML responses use Youch. In production,
737
+ * configured Inertia apps can render an Inertia status page.
738
+ * @docs https://docs.sailscasts.com/boring-stack/error-handling
739
+ * @param {Request} req - The request object
740
+ * @param {Response} res - The response object
741
+ * @param {{ statusCode?: number, error?: ErrorLike|string|Record<string, any>|null, page?: string }} [options] - Error page options
742
+ * @returns {*} - Response
743
+ */
744
+ handleErrorPage(req, res, options) {
745
+ return handleServerError.handleErrorPage(req, res, options)
746
+ },
747
+
656
748
  /**
657
749
  * Configure paginated data for infinite scrolling.
658
750
  * Wraps Waterline paginated data with proper merge behavior and normalizes
@@ -3,6 +3,33 @@
3
3
  * @typedef {import('./types').InertiaResponse} InertiaResponse
4
4
  * @typedef {import('./types').BadRequestData} BadRequestData
5
5
  */
6
+ const { INERTIA } = require('./helpers/inertia-headers')
7
+ const humanizeValidationErrors = require('./helpers/humanize-validation-errors')
8
+ const {
9
+ getValidateOnlyFields,
10
+ isPrecognitiveRequest,
11
+ sendPrecognitionErrors,
12
+ sendPrecognitionSuccess
13
+ } = require('./helpers/precognition')
14
+
15
+ /**
16
+ * @param {Record<string, string[]>} errors
17
+ * @param {string[]} fields
18
+ * @returns {Record<string, string[]>}
19
+ */
20
+ function filterErrors(errors, fields) {
21
+ if (fields.length === 0) {
22
+ return errors
23
+ }
24
+
25
+ return fields.reduce((result, field) => {
26
+ if (errors[field]) {
27
+ result[field] = errors[field]
28
+ }
29
+
30
+ return result
31
+ }, /** @type {Record<string, string[]>} */ ({}))
32
+ }
6
33
 
7
34
  /**
8
35
  * Handle bad request responses for Inertia.js
@@ -24,73 +51,52 @@
24
51
  */
25
52
  module.exports = function handleBadRequest(req, res, optionalData) {
26
53
  const sails = req._sails
54
+ const log = sails.log || {}
55
+ const response = /** @type {any} */ (res)
27
56
  // Define the status code to send in the response.
28
57
  const statusCodeToSet = 400
58
+ const errors = humanizeValidationErrors(optionalData)
59
+
60
+ if (isPrecognitiveRequest(req)) {
61
+ const filteredErrors = filterErrors(errors, getValidateOnlyFields(req))
29
62
 
30
- // Check if it's an Inertia request
31
- if (req.header?.('X-Inertia')) {
32
63
  if (
33
- optionalData &&
34
- !(optionalData instanceof Error) &&
35
- Array.isArray(optionalData.problems)
64
+ Object.keys(errors).length > 0 &&
65
+ Object.keys(filteredErrors).length === 0
36
66
  ) {
37
- /** @type {Record<string, string[]>} */
38
- const errors = {}
39
- optionalData.problems.forEach((problem) => {
40
- if (typeof problem === 'object') {
41
- Object.keys(problem).forEach((propertyName) => {
42
- const sanitizedProblem = String(problem[propertyName]).replace(
43
- /\.$/,
44
- ''
45
- ) // Trim trailing dot
46
- if (!errors[propertyName]) {
47
- errors[propertyName] = [sanitizedProblem]
48
- } else {
49
- errors[propertyName].push(sanitizedProblem)
50
- }
51
- })
52
- } else {
53
- const regex = /"(.*?)"/
54
- const matches = problem.match(regex)
67
+ return sendPrecognitionSuccess(res)
68
+ }
55
69
 
56
- if (matches && matches.length > 1) {
57
- const propertyName = matches[1]
58
- const sanitizedProblem = problem
59
- .replace(/"([^"]+)"/, '$1')
60
- .replace('\n', '')
61
- .replace('·', '')
62
- .trim()
63
- if (!errors[propertyName]) {
64
- errors[propertyName] = [sanitizedProblem]
65
- } else {
66
- errors[propertyName].push(sanitizedProblem)
67
- }
68
- }
69
- }
70
- })
70
+ return sendPrecognitionErrors(res, filteredErrors)
71
+ }
72
+
73
+ // Check if it's an Inertia request
74
+ if (req.header?.(INERTIA)) {
75
+ if (Object.keys(errors).length > 0) {
76
+ req.session = req.session || {}
71
77
  req.session.errors = errors
72
- return res.redirect(303, req.get('Referrer') || '/')
78
+ return response.redirect(303, req.get('Referrer') || '/')
73
79
  }
74
80
  }
75
81
 
76
82
  // If not an Inertia request, perform the normal badRequest response
77
83
  if (optionalData === undefined) {
78
- sails.log.info('Ran custom response: res.badRequest()')
79
- return res.sendStatus(statusCodeToSet)
84
+ log.info?.('Ran custom response: res.badRequest()')
85
+ return response.sendStatus(statusCodeToSet)
80
86
  } else if (optionalData instanceof Error) {
81
- sails.log.info(
87
+ log.info?.(
82
88
  'Custom response `res.badRequest()` called with an Error:',
83
89
  optionalData
84
90
  )
85
91
 
86
92
  if (typeof (/** @type {*} */ (optionalData).toJSON) !== 'function') {
87
93
  if (process.env.NODE_ENV === 'production') {
88
- return res.sendStatus(statusCodeToSet)
94
+ return response.sendStatus(statusCodeToSet)
89
95
  } else {
90
- return res.status(statusCodeToSet).send(optionalData.stack)
96
+ return response.status(statusCodeToSet).send(optionalData.stack)
91
97
  }
92
98
  }
93
99
  } else {
94
- return res.status(statusCodeToSet).send(optionalData)
100
+ return response.status(statusCodeToSet).send(optionalData)
95
101
  }
96
102
  }