inertia-sails
Version:
The Sails adapter for Inertia.
836 lines (781 loc) • 30.3 kB
JavaScript
/**
* Inertia.js Hook for Sails.js
*
* Provides server-side Inertia.js protocol support for The Boring JavaScript Stack.
*
* @module inertia-sails
* @description A hook definition that extends Sails by adding Inertia.js support.
* @docs https://docs.sailscasts.com/boring-stack/inertia
*/
/**
* @typedef {import('./lib/types').InertiaRequest} Request
* @typedef {import('./lib/types').InertiaResponse} Response
* @typedef {import('./lib/types').InertiaProps} InertiaProps
* @typedef {import('./lib/types').SailsLike} SailsLike
* @typedef {import('./lib/types').PropCallback} PropCallback
* @typedef {import('./lib/types').BadRequestData} BadRequestData
* @typedef {(req: Request, res: Response, next: () => any) => any} Middleware
* @typedef {Record<string, any>} InertiaHook
* @typedef {{ message?: string, stack?: string, name?: string }} ErrorLike
*
* @typedef {Object} InertiaRenderData
* @property {string} page - The component name to render
* @property {InertiaProps} [props] - Props to pass to the component
* @property {InertiaProps} [locals] - Additional locals for the root EJS template
* @property {boolean} [ssr] - Whether to server-render this response when SSR is enabled
*
* @typedef {Object} DeferOptions
* @property {boolean} [rescue=false] - Rescue callback failures
*
* @typedef {Object} ScrollOptions
* @property {number} [page=0] - Current page index (0-based)
* @property {number} [perPage=10] - Items per page
* @property {number} [total=0] - Total number of items
* @property {string} [pageName='page'] - Query parameter name for pagination
* @property {string} [wrapper='data'] - Key to wrap the data in
* @property {string|null} [matchOn] - Optional field used to match items when merging
*/
const inertia = require('./lib/middleware/inertia-middleware')
const render = require('./lib/render')
const location = require('./lib/location')
const requestContext = require('./lib/helpers/request-context')
const {
getValidateOnlyFields,
isPrecognitiveRequest,
sendPrecognitionSuccess,
shouldValidateField
} = require('./lib/helpers/precognition')
const DeferProp = require('./lib/props/defer-prop')
const OptionalProp = require('./lib/props/optional-prop')
const MergeProp = require('./lib/props/merge-prop')
const AlwaysProp = require('./lib/props/always-prop')
const OnceProp = require('./lib/props/once-prop')
const ScrollProp = require('./lib/props/scroll-prop')
const handleBadRequest = require('./lib/handle-bad-request')
const handleServerError = require('./lib/responses/server-error')
/**
* @param {SailsLike} sails
* @returns {InertiaHook}
*/
module.exports = function defineInertiaHook(sails) {
/** @type {InertiaHook} */
let hook
const routesToBindInertiaTo = [
'GET r|^((?![^?]*\\/[^?\\/]+\\.[^?\\/]+(\\?.*)?).)*$|',
// (^^Leave out assets)
'POST /*',
'PATCH /*',
'PUT /*',
'DELETE /*'
]
// Fallback version for when manifest isn't available (startup, no Shipwright)
// Using startup timestamp ensures fresh assets on each server restart
const startupVersion = Date.now().toString(36)
/** @type {Middleware} */
const runWithRequestContext = (req, res, next) => {
if (requestContext.getContext()) return next()
requestContext.run(req, res, next)
}
/**
* Get asset version from Shipwright manifest.
* Automatically hashes the manifest content for cache busting.
* Falls back to startup timestamp if manifest not found.
* @returns {string} - 8-character hash or startup timestamp
*/
function getManifestVersion() {
try {
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const appPath = sails.config?.appPath || process.cwd()
const manifestPath = path.join(appPath, '.tmp/public/manifest.json')
const manifest = fs.readFileSync(manifestPath, 'utf8')
return crypto
.createHash('md5')
.update(manifest)
.digest('hex')
.substring(0, 8)
} catch (err) {
// Only warn if it's not a simple "file not found" error
// ENOENT is expected during initial startup or without Shipwright
if (err.code !== 'ENOENT') {
sails.log?.warn?.(
'inertia-sails: Could not read manifest.json for asset versioning:',
err.message
)
}
return startupVersion
}
}
return {
defaults: {
inertia: {
rootView: 'app',
errorPage: 'error',
errorStatuses: [403, 404, 500, 503],
// Auto-version from Shipwright manifest for cache busting
// Override in config/inertia.js if needed
version: () => getManifestVersion(),
history: {
encrypt: false
},
ssr: {
enabled: false,
bundle: '.tmp/ssr/inertia.mjs',
pages: false,
fallback: true
}
}
},
/**
* Configure phase — runs after all hooks' defaults are merged but BEFORE
* any hook's initialize(). This is the right place to inject HTTP middleware
* because the HTTP hook hasn't read the middleware order yet.
*/
configure: function () {
// Inject AsyncLocalStorage context middleware into the HTTP stack
// BEFORE the router. This guarantees context is available in ALL
// routes.before handlers from all hooks, regardless of hook load order.
//
// Lifecycle:
// cookieParser → session → bodyParser → ... → inertiaContext → router
//
// By the time any routes.before handler calls sails.inertia.share(),
// the request is already wrapped in AsyncLocalStorage context.
if (sails.config.http && sails.config.http.middleware) {
const mw = sails.config.http.middleware
// When order is not explicitly configured, Sails uses a default order
// internally. We need to set it here so we can inject inertiaContext
// before the router middleware.
if (!mw.order) {
mw.order = [
'cookieParser',
'session',
'bodyParser',
'compress',
'poweredBy',
'router',
'www',
'favicon'
]
}
if (mw.order.indexOf('inertiaContext') === -1) {
const routerIdx = mw.order.indexOf('router')
if (routerIdx !== -1) {
mw.order.splice(routerIdx, 0, 'inertiaContext')
}
}
if (!mw.inertiaContext) {
mw.inertiaContext = runWithRequestContext
}
}
},
initialize: async function () {
hook = this
sails.inertia = hook
// Global shared props (for app-wide data like app name, version)
// These are merged with request-scoped shares
sails.inertia.globalSharedProps = {}
sails.inertia.globalSharedLocals = {}
// Default history encryption from config
sails.inertia.defaultEncryptHistory = sails.config.inertia.history.encrypt
sails.on('router:before', function () {
routesToBindInertiaTo.forEach(function (routeAddress) {
sails.router.bind(routeAddress, inertia(hook))
})
})
},
/**
* Hook routes — fallback context setup for socket requests.
* HTTP requests already have context from the inertiaContext middleware.
* Socket requests bypass Express middleware, so they need this.
*/
routes: {
before: {
'GET /*': {
skipAssets: true,
fn: runWithRequestContext
},
'POST /*': runWithRequestContext,
'PUT /*': runWithRequestContext,
'PATCH /*': runWithRequestContext,
'DELETE /*': runWithRequestContext
}
},
/**
* Share a property for the current request.
* Uses AsyncLocalStorage to ensure data doesn't leak between concurrent requests.
* For global shares (app name, etc), use shareGlobally() instead.
* @param {string} key - The key of the property
* @param {*} value - The value of the property
* @returns {*} - The value that was shared
*/
share(key, value = null) {
const context = requestContext.getContext()
if (context) {
requestContext.setSharedProp(key, value)
return value
}
// Never fall back to global — that causes data to leak across requests.
// Use shareGlobally() for truly global data like app name.
sails.log.warn(
`sails.inertia.share('${key}') called outside request context. ` +
'Value was not stored. Use shareGlobally() for global data.'
)
return value
},
/**
* Share a property globally across all requests.
* Use this for truly global data like app name, version, etc.
* For user-specific data, use share() instead.
* @param {string} key - The key of the property
* @param {*} value - The value of the property
* @returns {*} - The value that was shared
*/
shareGlobally(key, value = null) {
sails.inertia.globalSharedProps[key] = value
return value
},
/**
* Get shared properties (merges global + request-scoped)
* @param {string|null} key - The key of the property to get, or null to get all
* @returns {*} - The shared property or all shared properties
*/
getShared(key = null) {
const globalProps = sails.inertia.globalSharedProps
const requestProps = requestContext.getSharedProps()
const merged = { ...globalProps, ...requestProps }
return key ? merged[key] : merged
},
/**
* Flush shared properties for the current request.
* Always flushes from both request-scoped AND global storage to prevent
* stale data from leaking across requests.
* @param {string|null} key - The key of the property to flush, or null to flush all
*/
flushShared(key) {
const context = requestContext.getContext()
if (key) {
if (context) {
delete context.sharedProps[key]
}
// Always clean global too — prevents stale data from a previous
// request (or a share() that fell through before context was ready)
delete sails.inertia.globalSharedProps[key]
} else {
if (context) {
context.sharedProps = {}
}
sails.inertia.globalSharedProps = {}
}
},
/**
* Set a local for the current request's root EJS template.
* Uses AsyncLocalStorage to ensure data doesn't leak between concurrent requests.
* @param {string} key - The local variable name
* @param {*} value - The value
* @returns {*} - The value that was set
*/
local(key, value) {
const context = requestContext.getContext()
if (context) {
requestContext.setSharedLocal(key, value)
return value
}
sails.log.warn(
`sails.inertia.local('${key}') called outside request context. ` +
'Value was not stored. Use localGlobally() for global data.'
)
return value
},
/**
* Set a local globally across all requests.
* @param {string} key - The local variable name
* @param {*} value - The value
* @returns {*} - The value that was set
*/
localGlobally(key, value) {
sails.inertia.globalSharedLocals[key] = value
return value
},
/**
* Get locals (merges global + request-scoped)
* @param {string} key - The local variable name to get
* @returns {*} - The locals
*/
getLocals(key) {
const globalData = sails.inertia.globalSharedLocals
const requestData = requestContext.getSharedLocals()
const merged = { ...globalData, ...requestData }
return key ? merged[key] : merged
},
/**
* Create an optional prop
* This allows you to define properties that are only evaluated when accessed.
* @docs https://docs.sailscasts.com/boring-stack/partial-reloads#lazy-data-evaluation
* @param {PropCallback} callback - The callback function to execute
* @returns {OptionalProp} - The optional prop
*/
optional(callback) {
return new OptionalProp(callback)
},
/**
* Create a mergeable prop
* This allows you to merge multiple props together.
* @docs https://docs.sailscasts.com/boring-stack/merging-props
* @param {PropCallback} callback - The callback function to execute
* @returns {MergeProp} - The mergeable prop
*/
merge(callback) {
return new MergeProp(callback)
},
/**
* Create an always prop
* Always props are resolved on every request, whether partial or not.
* @docs https://docs.sailscasts.com/boring-stack/partial-reloads#lazy-data-evaluation
* @param {PropCallback} callback - The callback function
* @returns {AlwaysProp} - The always prop
*/
always(callback) {
return new AlwaysProp(callback)
},
/**
* Create a deferred prop
* This allows you to load certain page data after the initial render.
* @docs https://docs.sailscasts.com/boring-stack/deferred-props
* @param {PropCallback} cb - The callback function to execute
* @param {string|DeferOptions} group - The group name, or options when no group is needed
* @param {DeferOptions} options - Deferred prop options
* @returns {DeferProp} - The deferred prop
*/
defer(cb, group = 'default', options = {}) {
return new DeferProp(cb, group, options)
},
/**
* Create a once prop
* Once props are resolved only once and cached across navigations.
* The client tracks which props it has via X-Inertia-Except-Once-Props header.
* Useful for expensive computations that don't change often.
* @docs https://docs.sailscasts.com/boring-stack/once-props
* @param {PropCallback} callback - The callback function to execute
* @returns {OnceProp} - The once prop
* @example
* // Basic usage
* permissions: sails.inertia.once(async () => {
* return await Permission.find({ user: this.req.session.userId })
* })
* @example
* // With expiration (1 hour)
* permissions: sails.inertia.once(() => fetchPermissions()).until(3600)
* @example
* // Force refresh
* permissions: sails.inertia.once(() => fetchPermissions()).fresh()
*/
once(callback) {
return new OnceProp(callback)
},
/**
* Share a once prop for the current request.
* Combines share() and once() - the prop is shared and only resolved once.
* @docs https://docs.sailscasts.com/boring-stack/once-props#share-once
* @param {string} key - The key of the property
* @param {PropCallback} callback - The callback function to execute
* @returns {OnceProp} - The once prop (for chaining)
* @example
* // In a policy or middleware
* sails.inertia.shareOnce('permissions', async () => {
* return await Permission.find({ user: req.session.userId })
* })
*/
shareOnce(key, callback) {
const onceProp = new OnceProp(callback)
this.share(key, onceProp)
return onceProp
},
/**
* Mark a once-prop to be refreshed on the next response.
* Use this after updating data that's cached with once() (e.g., user profile).
* The prop will be force-sent to the client even if they have it cached.
* @docs https://docs.sailscasts.com/boring-stack/once-props#refreshing-once-props
* @param {string|string[]} keys - The prop key(s) to refresh
* @returns {Object} - The hook instance for chaining
* @example
* // After updating user profile
* await User.updateOne({ id: userId }).set({ fullName })
* sails.inertia.refreshOnce('loggedInUser')
* @example
* // Refresh multiple props
* sails.inertia.refreshOnce(['loggedInUser', 'teams', 'currentTeam'])
*/
refreshOnce(keys) {
const keysArray = Array.isArray(keys) ? keys : [keys]
keysArray.forEach((key) => {
requestContext.addRefreshOnceProp(key)
})
return this
},
/**
* Get the list of once-props to force-refresh for this request.
* @returns {string[]} - Array of prop keys to refresh
*/
getRefreshOnceProps() {
return requestContext.getRefreshOnceProps()
},
/**
* Flash data to the next Inertia response.
* Unlike regular props, flash data is NOT persisted in browser history.
* This prevents "phantom" toasts/notifications when users navigate back.
* Flash data is stored in the session so it persists across redirects.
* @docs https://docs.sailscasts.com/boring-stack/flash
* @param {string|Record<string, any>} key - The key or an object of key-value pairs
* @param {*} [value] - The value (if key is a string)
* @returns {Object} - The hook instance for chaining
* @example
* // Single value
* sails.inertia.flash('success', 'Profile updated!')
* @example
* // Multiple values
* sails.inertia.flash({ success: 'Saved!', highlight: 'profile-section' })
*/
flash(key, value = null) {
const req = requestContext.getRequest()
if (!req || !req.session) {
sails.log.warn(
'sails.inertia.flash() called outside of request context'
)
return this
}
if (!req.session._inertiaFlash) {
req.session._inertiaFlash = {}
}
if (typeof key === 'object' && key !== null) {
req.session._inertiaFlash = { ...req.session._inertiaFlash, ...key }
} else {
const flashKey = /** @type {string} */ (key)
req.session._inertiaFlash[flashKey] = value
}
return this
},
/**
* Get the current flash data from the session
* @returns {Object} - The flash data object
*/
getFlash() {
const req = requestContext.getRequest()
return req?.session?._inertiaFlash || {}
},
/**
* Consume and clear flash data from the session.
* Called internally by build-page-object after adding to response.
* @param {Request} req - The request object
* @returns {InertiaProps} - The flash data that was consumed
*/
consumeFlash(req) {
const flash = req?.session?._inertiaFlash || {}
if (req?.session) {
req.session._inertiaFlash = {}
}
return flash
},
/**
* Create a deep merge prop
* Like merge(), but recursively merges nested objects instead of replacing them.
* @docs https://docs.sailscasts.com/boring-stack/merging-props#deep-merge
* @param {PropCallback} callback - The callback function to execute
* @returns {MergeProp} - The mergeable prop with deep merge enabled
* @example
* // Deep merge nested user preferences
* settings: sails.inertia.deepMerge(async () => {
* return await Settings.findOne({ user: this.req.session.userId })
* })
*/
deepMerge(callback) {
return new MergeProp(callback).deepMerge()
},
/**
* Render the response
* @param {Request} req - The request object
* @param {Response} res - The response object
* @param {InertiaRenderData} data - The data to render
* @returns {*} - The rendered response
*/
render(req, res, data) {
return render(req, res, data)
},
/**
* Handle Inertia redirects (external URLs or non-Inertia pages)
* Forces a full page visit instead of an Inertia XHR request.
* See https://docs.sailscasts.com/boring-stack/redirects
* @param {Request} req - The request object
* @param {Response} res - The response object
* @param {string} url - The URL to redirect to
* @returns {Object} - The response object with the redirect
*/
location(req, res, url) {
return location(req, res, url)
},
/**
* Encrypt history for the current request.
* Uses AsyncLocalStorage to ensure setting doesn't affect other requests.
* @docs https://docs.sailscasts.com/boring-stack/history-encryption
* @param {boolean} encrypt - Whether to encrypt the history
*/
encryptHistory(encrypt = true) {
const context = requestContext.getContext()
if (context) {
requestContext.setEncryptHistory(encrypt)
} else {
// Fallback: set default if called outside request
sails.inertia.defaultEncryptHistory = encrypt
}
},
/**
* Get whether history should be encrypted for the current request.
* Returns request-scoped value if set, otherwise returns default.
* @returns {boolean} - Whether to encrypt history
*/
shouldEncryptHistory() {
const requestSetting = requestContext.getEncryptHistory()
if (requestSetting !== null) {
return requestSetting
}
return sails.inertia.defaultEncryptHistory
},
/**
* Clear history state for the current request.
* Uses AsyncLocalStorage to ensure setting doesn't affect other requests.
* @docs https://docs.sailscasts.com/boring-stack/history-encryption#clearing-history
*/
clearHistory() {
const context = requestContext.getContext()
if (context) {
requestContext.setClearHistory(true)
}
},
/**
* Get whether history should be cleared for the current request.
* @returns {boolean} - Whether to clear history
*/
shouldClearHistory() {
return requestContext.getClearHistory()
},
/**
* Preserve the current URL fragment across a standard Inertia redirect.
* The flag is stored in the session so it survives the redirect request,
* then it is consumed by the next Inertia page response.
*
* @docs https://docs.sailscasts.com/boring-stack/redirects#preserving-fragments
* @param {boolean} preserve - Whether to preserve the URL fragment
* @returns {Object} - The hook instance for chaining
* @example
* sails.inertia.preserveFragment()
* return '/article/new-slug'
*/
preserveFragment(preserve = true) {
const context = requestContext.getContext()
const req = requestContext.getRequest()
if (context) {
requestContext.setPreserveFragment(preserve)
}
if (req?.session) {
if (preserve) {
req.session._inertiaPreserveFragment = true
} else {
delete req.session._inertiaPreserveFragment
}
}
return this
},
/**
* Consume the preserve fragment flag for the current request.
* @param {Request} req - The request object
* @returns {boolean} - Whether to preserve the URL fragment
*/
consumePreserveFragment(req) {
const preserve =
requestContext.getPreserveFragment() ||
Boolean(req?.session?._inertiaPreserveFragment)
if (req?.session) {
delete req.session._inertiaPreserveFragment
}
return preserve
},
/**
* Handle bad request responses for Inertia.js
* For Inertia requests with validation errors, redirects back with errors in session.
* @param {Request} req - The request object
* @param {Response} res - The response object
* @param {BadRequestData|Error|Record<string, any>} [optionalData] - Optional error data or Error object
* @returns {*} - Response (redirect for Inertia, status code for non-Inertia)
*/
handleBadRequest(req, res, optionalData) {
return handleBadRequest(req, res, optionalData)
},
/**
* Determine if the current request is a Precognition validation request.
* @param {Request} [req] - The request object. Defaults to request context.
* @returns {boolean} - Whether this is a Precognition request.
*/
isPrecognitive(req) {
return isPrecognitiveRequest(req || requestContext.getRequest())
},
/**
* Get the fields requested by Precognition-Validate-Only.
* @param {Request} [req] - The request object. Defaults to request context.
* @returns {string[]} - Fields requested for validation.
*/
validateOnly(req) {
return getValidateOnlyFields(req || requestContext.getRequest())
},
/**
* Determine if a field should run validation for the current request.
* For non-Precognition requests, all fields should validate.
* @param {string} field - Field name, with dot notation and * wildcards supported.
* @param {Request} [req] - The request object. Defaults to request context.
* @returns {boolean} - Whether to validate this field.
*/
shouldValidate(field, req) {
return shouldValidateField(req || requestContext.getRequest(), field)
},
/**
* Handle a successful Precognition validation response.
* This is intended for app-level custom responses such as
* `api/responses/precognitionSuccess.js`.
* @param {Request} req - The request object
* @param {Response} res - The response object
* @returns {*} - A 204 Precognition response.
*/
handlePrecognitionSuccess(req, res) {
if (!res) {
throw new Error(
'sails.inertia.handlePrecognitionSuccess() called without a response object'
)
}
return sendPrecognitionSuccess(res)
},
/**
* Send a successful Precognition validation response.
* Low-level alias for custom responses and non-action callers.
* In actions with a responseType, prefer a named exit using a
* `precognitionSuccess` response file.
* @param {Request} [req] - The request object. Defaults to request context.
* @param {Response} [res] - The response object. Defaults to request context.
* @returns {*} - A 204 Precognition response.
*/
precognitionSuccess(req, res) {
return this.handlePrecognitionSuccess(
req || requestContext.getRequest(),
res || requestContext.getResponse()
)
},
/**
* Handle server error responses for Inertia.js
* For Inertia requests in development, displays a styled error modal with stack trace.
* In production, redirects back with a flash error message.
* @docs https://docs.sailscasts.com/boring-stack/error-handling
* @param {Request} req - The request object
* @param {Response} res - The response object
* @param {ErrorLike} [error] - Optional error data or Error object
* @returns {*} - Response (HTML modal for dev Inertia, redirect for prod)
*/
handleServerError(req, res, error) {
return handleServerError(req, res, error)
},
/**
* Handle application status/error pages for Inertia.js.
* In development, 500-level HTML responses use Youch. In production,
* configured Inertia apps can render an Inertia status page.
* @docs https://docs.sailscasts.com/boring-stack/error-handling
* @param {Request} req - The request object
* @param {Response} res - The response object
* @param {{ statusCode?: number, error?: ErrorLike|string|Record<string, any>|null, page?: string }} [options] - Error page options
* @returns {*} - Response
*/
handleErrorPage(req, res, options) {
return handleServerError.handleErrorPage(req, res, options)
},
/**
* Configure paginated data for infinite scrolling.
* Wraps Waterline paginated data with proper merge behavior and normalizes
* pagination metadata for Inertia's <InfiniteScroll> component.
*
* Note: Waterline uses 0-based page indexes, but the metadata is normalized
* to 1-based for the Inertia client.
*
* @docs https://docs.sailscasts.com/boring-stack/infinite-scroll
* @param {PropCallback} callback - Callback returning the paginated data array
* @param {ScrollOptions} [options] - Pagination options
* @returns {ScrollProp} - The scroll prop
* @example
* // Basic usage
* const page = this.req.param('page', 0)
* const perPage = 20
* const users = await User.find().paginate(page, perPage)
* const total = await User.count()
*
* return {
* page: 'users/index',
* props: {
* users: sails.inertia.scroll(() => users, { page, perPage, total })
* }
* }
*/
scroll(callback, options) {
return new ScrollProp(callback, options || {})
},
/**
* Set the root view template for the current request.
* Allows using different layouts for different pages (e.g., 'app' vs 'auth').
* @docs https://docs.sailscasts.com/boring-stack/root-template
* @param {string} view - The root view template name (without extension)
* @returns {Object} - The hook instance for chaining
* @example
* // In a policy for auth pages
* module.exports = async function(req, res, proceed) {
* sails.inertia.setRootView('auth')
* return proceed()
* }
* @example
* // In an action
* fn: async function() {
* sails.inertia.setRootView('minimal')
* return { page: 'embed/widget', props: { ... } }
* }
*/
setRootView(view) {
requestContext.setRootView(view)
return this
},
/**
* Get the root view template for the current request.
* Returns request-scoped value if set, otherwise returns config default.
* @returns {string} - The root view template name
*/
getRootView() {
return requestContext.getRootView() || sails.config.inertia.rootView
},
/**
* Get the URL to redirect back to.
* Uses the Referer header with a fallback URL.
*
* Note: The HTTP Referer header is unreliable (privacy extensions, cross-origin).
* For critical back navigation, consider session-based tracking.
*
* @param {string} [fallback='/'] - Fallback URL if Referer is not available
* @returns {string} - The URL to redirect back to
* @example
* // In an action
* return sails.inertia.back('/dashboard')
* @example
* // With inertiaRedirect response
* return { inertiaRedirect: sails.inertia.back() }
*/
back(fallback = '/') {
const req = requestContext.getRequest()
if (!req) {
return fallback
}
return req.get('Referer') || fallback
}
}
}