UNPKG

inertia-sails

Version:
836 lines (781 loc) 30.3 kB
/** * 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 } } }