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 +177 -1
- package/index.js +92 -0
- package/lib/handle-bad-request.js +51 -45
- package/lib/helpers/humanize-validation-errors.js +590 -0
- package/lib/helpers/inertia-headers.js +10 -1
- package/lib/helpers/precognition.js +150 -0
- package/lib/helpers/vary-header.js +26 -0
- package/lib/render.js +13 -1
- package/lib/responses/server-error.js +422 -288
- package/lib/ssr.js +200 -0
- package/lib/types.js +3 -1
- package/package.json +4 -2
- package/tests/handle-bad-request.test.js +318 -0
- package/tests/helpers/precognition.test.js +172 -0
- package/tests/helpers/preserve-fragment.test.js +21 -0
- package/tests/props/resolve-page-props.test.js +29 -0
- package/tests/render.test.js +121 -2
- package/tests/server-error.test.js +280 -0
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
|
-
|
|
34
|
-
|
|
35
|
-
Array.isArray(optionalData.problems)
|
|
64
|
+
Object.keys(errors).length > 0 &&
|
|
65
|
+
Object.keys(filteredErrors).length === 0
|
|
36
66
|
) {
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
return
|
|
84
|
+
log.info?.('Ran custom response: res.badRequest()')
|
|
85
|
+
return response.sendStatus(statusCodeToSet)
|
|
80
86
|
} else if (optionalData instanceof Error) {
|
|
81
|
-
|
|
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
|
|
94
|
+
return response.sendStatus(statusCodeToSet)
|
|
89
95
|
} else {
|
|
90
|
-
return
|
|
96
|
+
return response.status(statusCodeToSet).send(optionalData.stack)
|
|
91
97
|
}
|
|
92
98
|
}
|
|
93
99
|
} else {
|
|
94
|
-
return
|
|
100
|
+
return response.status(statusCodeToSet).send(optionalData)
|
|
95
101
|
}
|
|
96
102
|
}
|