diff --git a/.github/workflows/inertia-sails.yml b/.github/workflows/inertia-sails.yml new file mode 100644 index 00000000..ba842717 --- /dev/null +++ b/.github/workflows/inertia-sails.yml @@ -0,0 +1,22 @@ +name: Inertia Sails + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Install dependencies + run: npm ci + + - name: Run inertia-sails checks + run: npm --workspace inertia-sails test diff --git a/package-lock.json b/package-lock.json index d2774b99..47a3b935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "boring-stack", - "version": "0.5.4", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "boring-stack", - "version": "0.5.4", + "version": "1.2.3", "license": "MIT", "workspaces": [ "packages/*" @@ -16,7 +16,8 @@ "@commitlint/config-conventional": "^17.6.6", "husky": "^8.0.3", "lint-staged": "^13.2.2", - "prettier": "2.8.8" + "prettier": "2.8.8", + "typescript": "^5.7.3" } }, "node_modules/@babel/code-frame": { @@ -66,18 +67,6 @@ "sisteransi": "^1.0.5" } }, - "node_modules/@clack/prompts/node_modules/is-unicode-supported": { - "version": "1.3.0", - "extraneous": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@commitlint/cli": { "version": "17.8.1", "dev": true, @@ -6235,7 +6224,7 @@ } }, "packages/create-sails": { - "version": "1.0.4", + "version": "1.2.1", "license": "MIT", "dependencies": { "@clack/prompts": "^0.6.3", @@ -6252,7 +6241,7 @@ } }, "packages/create-sails-generator": { - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "devDependencies": {}, "peerDependencies": { @@ -6260,7 +6249,7 @@ } }, "packages/inertia-sails": { - "version": "0.3.3", + "version": "1.4.0", "license": "MIT", "devDependencies": {}, "peerDependencies": { diff --git a/package.json b/package.json index f379771e..8249b827 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "@commitlint/config-conventional": "^17.6.6", "husky": "^8.0.3", "lint-staged": "^13.2.2", - "prettier": "2.8.8" + "prettier": "2.8.8", + "typescript": "^5.7.3" }, "workspaces": [ "packages/*" diff --git a/packages/inertia-sails/README.md b/packages/inertia-sails/README.md index dd7ba073..02ec8b9c 100644 --- a/packages/inertia-sails/README.md +++ b/packages/inertia-sails/README.md @@ -38,7 +38,10 @@ module.exports.inertia = { <%- shipwright.styles() %> -
+
+ <%- shipwright.scripts() %> @@ -87,6 +90,19 @@ Return a URL string to redirect: return '/dashboard' ``` +#### Preserving URL fragments + +When a standard Inertia redirect should carry the current hash to the next +page, mark the redirect before returning the URL: + +```js +sails.inertia.preserveFragment() +return '/articles/new-slug' +``` + +If the user started from `/articles/old-slug#comments`, the Inertia client can +carry `#comments` to the redirected page. + ### Sharing Data #### `share(key, value)` @@ -191,6 +207,25 @@ return { } ``` +Deferred props can also be rescued when a non-critical callback fails: + +```js +return { + page: 'dashboard', + props: { + analytics: sails.inertia + .defer(async () => { + return await Analytics.getExpensiveReport() + }) + .rescue() + } +} +``` + +When a rescued deferred prop throws, it is omitted from `props` and its key is +reported in `rescuedProps`, allowing the client `` component to show +its rescue slot instead of failing the whole deferred response. + ### Merge Props Merge with existing client-side data (useful for infinite scroll): @@ -199,13 +234,29 @@ Merge with existing client-side data (useful for infinite scroll): // Shallow merge messages: sails.inertia.merge(() => newMessages) +// Prepend new items instead of appending +notifications: sails.inertia.merge(() => newNotifications).prepend() + +// Merge a nested array inside a paginated object +users: sails.inertia.merge(() => paginatedUsers).append('data') + +// Match existing items by ID when merging +users: sails.inertia + .merge(() => paginatedUsers) + .append('data', { + matchOn: 'id' + }) + // Deep merge (nested objects) settings: sails.inertia.deepMerge(() => updatedSettings) + +// Deep merge with item matching +chat: sails.inertia.deepMerge(() => chatState).matchOn('messages.id') ``` ### Infinite Scroll -Paginate data with automatic merge behavior. Works with Inertia.js v2's `` component: +Paginate data with automatic merge behavior. Works with Inertia's `` component: ```js // Controller @@ -244,6 +295,8 @@ defineProps({ invoices: Object }) ``` +`scroll()` targets the wrapped array for merging, such as `invoices.data`, and follows Inertia's infinite-scroll merge intent header so previous-page requests prepend while next-page requests append. + ### History Encryption Encrypt sensitive data in browser history: diff --git a/packages/inertia-sails/index.js b/packages/inertia-sails/index.js index b554d868..a66292f3 100644 --- a/packages/inertia-sails/index.js +++ b/packages/inertia-sails/index.js @@ -9,32 +9,31 @@ */ /** - * @typedef {import('express').Request} Request - * @typedef {import('express').Response} Response - */ - -/** - * @typedef {Object} InertiaConfig - * @property {string} [rootView='app'] - The root view template to use - * @property {string|number|Function} [version=1] - Asset version for cache busting - * @property {Object} [history] - History encryption settings - * @property {boolean} [history.encrypt=false] - Whether to encrypt history state - */ - -/** - * @typedef {Object} InertiaPageProps - * @property {Object.} [props] - Page props to pass to the component - */ - -/** + * @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} InertiaHook + * @typedef {{ message?: string, stack?: string, name?: string }} ErrorLike + * * @typedef {Object} InertiaRenderData * @property {string} page - The component name to render - * @property {Object.} [props] - Props to pass to the component - * @property {Object.} [locals] - Additional locals for the root EJS template - */ - -/** - * @typedef {() => T | Promise} PropCallback + * @property {InertiaProps} [props] - Props to pass to the component + * @property {InertiaProps} [locals] - Additional locals for the root EJS template + * + * @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') @@ -51,7 +50,12 @@ 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|^((?![^?]*\\/[^?\\/]+\\.[^?\\/]+(\\?.*)?).)*$|', @@ -66,6 +70,12 @@ module.exports = function defineInertiaHook(sails) { // 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. @@ -150,9 +160,7 @@ module.exports = function defineInertiaHook(sails) { } } if (!mw.inertiaContext) { - mw.inertiaContext = function inertiaContext(req, res, next) { - requestContext.run(req, res, next) - } + mw.inertiaContext = runWithRequestContext } } }, @@ -182,28 +190,12 @@ module.exports = function defineInertiaHook(sails) { before: { 'GET /*': { skipAssets: true, - fn: (req, res, next) => { - // Skip if context already set up by HTTP middleware - if (requestContext.getContext()) return next() - requestContext.run(req, res, next) - } - }, - 'POST /*': (req, res, next) => { - if (requestContext.getContext()) return next() - requestContext.run(req, res, next) - }, - 'PUT /*': (req, res, next) => { - if (requestContext.getContext()) return next() - requestContext.run(req, res, next) + fn: runWithRequestContext }, - 'PATCH /*': (req, res, next) => { - if (requestContext.getContext()) return next() - requestContext.run(req, res, next) - }, - 'DELETE /*': (req, res, next) => { - if (requestContext.getContext()) return next() - requestContext.run(req, res, next) - } + 'POST /*': runWithRequestContext, + 'PUT /*': runWithRequestContext, + 'PATCH /*': runWithRequestContext, + 'DELETE /*': runWithRequestContext } }, @@ -325,7 +317,7 @@ module.exports = function defineInertiaHook(sails) { * 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 {Function} callback - The callback function to execute + * @param {PropCallback} callback - The callback function to execute * @returns {OptionalProp} - The optional prop */ optional(callback) { @@ -336,7 +328,7 @@ module.exports = function defineInertiaHook(sails) { * Create a mergeable prop * This allows you to merge multiple props together. * @docs https://docs.sailscasts.com/boring-stack/merging-props - * @param {Function} callback - The callback function to execute + * @param {PropCallback} callback - The callback function to execute * @returns {MergeProp} - The mergeable prop */ merge(callback) { @@ -347,7 +339,7 @@ module.exports = function defineInertiaHook(sails) { * 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 {Function} callback - The callback function + * @param {PropCallback} callback - The callback function * @returns {AlwaysProp} - The always prop */ always(callback) { @@ -357,12 +349,13 @@ module.exports = function defineInertiaHook(sails) { * 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 {Function} cb - The callback function to execute - * @param {string} group - The group name + * @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') { - return new DeferProp(cb, group) + defer(cb, group = 'default', options = {}) { + return new DeferProp(cb, group, options) }, /** @@ -371,7 +364,7 @@ module.exports = function defineInertiaHook(sails) { * 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 {Function} callback - The callback function to execute + * @param {PropCallback} callback - The callback function to execute * @returns {OnceProp} - The once prop * @example * // Basic usage @@ -394,7 +387,7 @@ module.exports = function defineInertiaHook(sails) { * 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 {Function} callback - The callback function to execute + * @param {PropCallback} callback - The callback function to execute * @returns {OnceProp} - The once prop (for chaining) * @example * // In a policy or middleware @@ -445,7 +438,7 @@ module.exports = function defineInertiaHook(sails) { * 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|Object} key - The key or an object of key-value pairs + * @param {string|Record} 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 @@ -469,7 +462,8 @@ module.exports = function defineInertiaHook(sails) { if (typeof key === 'object' && key !== null) { req.session._inertiaFlash = { ...req.session._inertiaFlash, ...key } } else { - req.session._inertiaFlash[key] = value + const flashKey = /** @type {string} */ (key) + req.session._inertiaFlash[flashKey] = value } return this }, @@ -486,8 +480,8 @@ module.exports = function defineInertiaHook(sails) { /** * Consume and clear flash data from the session. * Called internally by build-page-object after adding to response. - * @param {Object} req - The request object - * @returns {Object} - The flash data that was consumed + * @param {Request} req - The request object + * @returns {InertiaProps} - The flash data that was consumed */ consumeFlash(req) { const flash = req?.session?._inertiaFlash || {} @@ -501,7 +495,7 @@ module.exports = function defineInertiaHook(sails) { * 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 {Function} callback - The callback function to execute + * @param {PropCallback} callback - The callback function to execute * @returns {MergeProp} - The mergeable prop with deep merge enabled * @example * // Deep merge nested user preferences @@ -515,9 +509,9 @@ module.exports = function defineInertiaHook(sails) { /** * Render the response - * @param {Object} req - The request object - * @param {Object} res - The response object - * @param {Object} data - The data to render + * @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) { @@ -527,8 +521,8 @@ module.exports = function defineInertiaHook(sails) { * 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 {Object} req - The request object - * @param {Object} res - The response object + * @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 */ @@ -585,12 +579,60 @@ module.exports = function defineInertiaHook(sails) { 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 {Object} req - The request object - * @param {Object} res - The response object - * @param {Object|Error} [optionalData] - Optional error data or Error object + * @param {Request} req - The request object + * @param {Response} res - The response object + * @param {BadRequestData|Error|Record} [optionalData] - Optional error data or Error object * @returns {*} - Response (redirect for Inertia, status code for non-Inertia) */ handleBadRequest(req, res, optionalData) { @@ -602,9 +644,9 @@ module.exports = function defineInertiaHook(sails) { * 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 {Object} req - The request object - * @param {Object} res - The response object - * @param {Object|Error} [error] - Optional error data or Error object + * @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) { @@ -620,13 +662,8 @@ module.exports = function defineInertiaHook(sails) { * to 1-based for the Inertia client. * * @docs https://docs.sailscasts.com/boring-stack/infinite-scroll - * @param {Function} callback - Callback returning the paginated data array - * @param {Object} [options] - Pagination options - * @param {number} [options.page=0] - Current page index (0-based) - * @param {number} [options.perPage=10] - Items per page - * @param {number} [options.total=0] - Total number of items - * @param {string} [options.pageName='page'] - Query parameter name for pagination - * @param {string} [options.wrapper='data'] - Key to wrap the data in + * @param {PropCallback} callback - Callback returning the paginated data array + * @param {ScrollOptions} [options] - Pagination options * @returns {ScrollProp} - The scroll prop * @example * // Basic usage diff --git a/packages/inertia-sails/lib/handle-bad-request.js b/packages/inertia-sails/lib/handle-bad-request.js index c6386356..b19540d4 100644 --- a/packages/inertia-sails/lib/handle-bad-request.js +++ b/packages/inertia-sails/lib/handle-bad-request.js @@ -1,3 +1,9 @@ +/** + * @typedef {import('./types').InertiaRequest} InertiaRequest + * @typedef {import('./types').InertiaResponse} InertiaResponse + * @typedef {import('./types').BadRequestData} BadRequestData + */ + /** * Handle bad request responses for Inertia.js * @@ -5,9 +11,9 @@ * previous page with errors stored in the session. For non-Inertia requests, * it returns a standard 400 response. * - * @param {Object} req - Express/Sails request object - * @param {Object} res - Express/Sails response object - * @param {Object|Error} [optionalData] - Optional error data or Error object + * @param {InertiaRequest} req - Express/Sails request object + * @param {InertiaResponse} res - Express/Sails response object + * @param {BadRequestData|Error|Record} [optionalData] - Optional error data or Error object * @returns {*} - Response (redirect for Inertia, status code for non-Inertia) * * @example @@ -22,13 +28,21 @@ module.exports = function handleBadRequest(req, res, optionalData) { const statusCodeToSet = 400 // Check if it's an Inertia request - if (req.header('X-Inertia')) { - if (optionalData && optionalData.problems) { + if (req.header?.('X-Inertia')) { + if ( + optionalData && + !(optionalData instanceof Error) && + Array.isArray(optionalData.problems) + ) { + /** @type {Record} */ const errors = {} optionalData.problems.forEach((problem) => { if (typeof problem === 'object') { Object.keys(problem).forEach((propertyName) => { - const sanitizedProblem = problem[propertyName].replace(/\.$/, '') // Trim trailing dot + const sanitizedProblem = String(problem[propertyName]).replace( + /\.$/, + '' + ) // Trim trailing dot if (!errors[propertyName]) { errors[propertyName] = [sanitizedProblem] } else { diff --git a/packages/inertia-sails/lib/helpers/build-page-object.js b/packages/inertia-sails/lib/helpers/build-page-object.js index 816ed93a..f23775fd 100644 --- a/packages/inertia-sails/lib/helpers/build-page-object.js +++ b/packages/inertia-sails/lib/helpers/build-page-object.js @@ -4,23 +4,103 @@ const resolveMergeProps = require('../props/resolve-merge-props') const { resolveOncePropsMetadata } = require('../props/resolve-once-props') const resolvePageProps = require('../props/resolve-page-props') const resolveScrollProps = require('../props/resolve-scroll-props') +const resolveAssetVersion = require('./resolve-asset-version') + +/** + * @typedef {Object} InertiaHookApi + * @property {() => Object.} getShared + * @property {() => boolean} shouldClearHistory + * @property {() => boolean} shouldEncryptHistory + * @property {(req: BuildPageObjectRequest) => boolean} consumePreserveFragment + * @property {(req: BuildPageObjectRequest) => Object.} consumeFlash + */ + +/** + * @typedef {Object} SailsLike + * @property {Object.} config + * @property {InertiaHookApi} inertia + */ + +/** + * @typedef {Object} BuildPageObjectRequest + * @property {string} [url] + * @property {string} [originalUrl] + * @property {SailsLike} _sails + * @property {(header: string) => string|undefined} get + */ /** * @typedef {Object} InertiaPageObject * @property {string} component - The component name to render * @property {string} url - The current URL - * @property {string|number} version - Asset version for cache busting + * @property {string|number|null} version - Asset version for cache busting * @property {Object.} props - Resolved page props - * @property {boolean} clearHistory - Whether to clear browser history - * @property {boolean} encryptHistory - Whether to encrypt history state + * @property {boolean} [clearHistory] - Whether to clear browser history + * @property {boolean} [encryptHistory] - Whether to encrypt history state + * @property {boolean} [preserveFragment] - Whether to preserve URL fragments across redirects + * @property {string[]} [sharedProps] - Shared prop keys included in this response * @property {string[]} [mergeProps] - Props that should be merged on client + * @property {string[]} [prependProps] - Props that should be prepended on client * @property {string[]} [deepMergeProps] - Props that should be deep merged + * @property {string[]} [matchPropsOn] - Prop paths to use for matching merge items * @property {Object.} [deferredProps] - Deferred props by group + * @property {string[]} [rescuedProps] - Deferred props rescued after callback failures * @property {Object.} [onceProps] - Once-prop metadata * @property {Object.} [scrollProps] - Scroll props for InfiniteScroll component * @property {Object.} [flash] - Flash data (not persisted in history) */ +/** + * @typedef {'mergeProps'|'prependProps'|'deepMergeProps'|'matchPropsOn'} PathMetadataKey + */ + +/** + * @param {InertiaPageObject} page + * @param {PathMetadataKey} key + */ +function removeEmptyArrayMetadata(page, key) { + if (Array.isArray(page[key]) && page[key].length === 0) { + delete page[key] + } +} + +/** + * @param {InertiaPageObject} page + * @param {string[]} rescuedProps + */ +function removeRescuedPropMetadata(page, rescuedProps) { + if (rescuedProps.length === 0) return + + const rescuedRootProps = new Set(rescuedProps) + /** @param {string} path */ + const isNotRescuedPath = (path) => !rescuedRootProps.has(path.split('.')[0]) + /** @type {PathMetadataKey[]} */ + const pathMetadataKeys = [ + 'mergeProps', + 'prependProps', + 'deepMergeProps', + 'matchPropsOn' + ] + + pathMetadataKeys.forEach((key) => { + if (Array.isArray(page[key])) { + page[key] = page[key].filter(isNotRescuedPath) + removeEmptyArrayMetadata(page, key) + } + }) + + const scrollProps = page.scrollProps + if (scrollProps) { + rescuedProps.forEach((key) => { + delete scrollProps[key] + }) + + if (Object.keys(scrollProps).length === 0) { + delete page.scrollProps + } + } +} + /** * Build the Inertia page object for a response. * @@ -30,42 +110,67 @@ const resolveScrollProps = require('../props/resolve-scroll-props') * Uses request-scoped shared props (via AsyncLocalStorage) merged with * global shared props to prevent data leaking between concurrent requests. * - * @param {Object} req - Express/Sails request object + * @param {BuildPageObjectRequest} req - Express/Sails request object * @param {string} component - The component name to render * @param {Object.} pageProps - Props specific to this page * @returns {Promise} - The complete page object */ module.exports = async function buildPageObject(req, component, pageProps) { const sails = req._sails - let url = req.url || req.originalUrl - const assetVersion = sails.config.inertia.version - const currentVersion = - typeof assetVersion === 'function' ? assetVersion() : assetVersion + let url = req.url || req.originalUrl || '/' + const currentVersion = resolveAssetVersion(sails) + + const sharedProps = sails.inertia.getShared() + const sharedPropKeys = Object.keys(sharedProps) // Merge props: global shared → request-scoped shared → page-specific // This ensures user-specific data (from share()) doesn't leak between requests const allProps = { - ...sails.inertia.getShared(), // Merges global + request-scoped + ...sharedProps, // Merges global + request-scoped ...pageProps } const propsToResolve = pickPropsToResolve(req, component, allProps) + const clearHistory = sails.inertia.shouldClearHistory() + const encryptHistory = sails.inertia.shouldEncryptHistory() + const preserveFragment = sails.inertia.consumePreserveFragment(req) + const resolvedPageProps = await resolvePageProps.withMetadata(propsToResolve) // Build the page object with all metadata // Use request-scoped history settings (prevents race conditions) + /** @type {InertiaPageObject} */ const page = { component, url, version: currentVersion, - props: await resolvePageProps(propsToResolve), - clearHistory: sails.inertia.shouldClearHistory(), - encryptHistory: sails.inertia.shouldEncryptHistory(), + props: resolvedPageProps.props, ...resolveMergeProps(req, allProps), ...resolveDeferredProps(req, component, allProps), ...resolveOncePropsMetadata(allProps), ...resolveScrollProps(allProps) } + if (clearHistory) { + page.clearHistory = true + } + + if (encryptHistory) { + page.encryptHistory = true + } + + if (preserveFragment) { + page.preserveFragment = true + } + + if (resolvedPageProps.rescuedProps.length > 0) { + page.rescuedProps = resolvedPageProps.rescuedProps + removeRescuedPropMetadata(page, resolvedPageProps.rescuedProps) + } + + if (sharedPropKeys.length > 0) { + page.sharedProps = sharedPropKeys + } + // Consume flash data from session and add to props // Flash data is included in props.flash so it's accessible via usePage().props.flash // Note: Unlike regular props, flash data should NOT be persisted in browser history diff --git a/packages/inertia-sails/lib/helpers/inertia-headers.js b/packages/inertia-sails/lib/helpers/inertia-headers.js index 679848a3..a8db8701 100644 --- a/packages/inertia-sails/lib/helpers/inertia-headers.js +++ b/packages/inertia-sails/lib/helpers/inertia-headers.js @@ -23,6 +23,8 @@ const PARTIAL_COMPONENT = 'X-Inertia-Partial-Component' const LOCATION = 'X-Inertia-Location' /** @type {string} - Comma-separated list of once-props client already has */ const EXCEPT_ONCE_PROPS = 'X-Inertia-Except-Once-Props' +/** @type {string} - InfiniteScroll merge direction */ +const INFINITE_SCROLL_MERGE_INTENT = 'X-Inertia-Infinite-Scroll-Merge-Intent' module.exports = { INERTIA, @@ -33,5 +35,6 @@ module.exports = { RESET, PARTIAL_COMPONENT, LOCATION, - EXCEPT_ONCE_PROPS + EXCEPT_ONCE_PROPS, + INFINITE_SCROLL_MERGE_INTENT } diff --git a/packages/inertia-sails/lib/helpers/is-inertia-partial-request.js b/packages/inertia-sails/lib/helpers/is-inertia-partial-request.js index a5971ab7..1a4d714e 100644 --- a/packages/inertia-sails/lib/helpers/is-inertia-partial-request.js +++ b/packages/inertia-sails/lib/helpers/is-inertia-partial-request.js @@ -1,4 +1,14 @@ const { PARTIAL_COMPONENT } = require('./inertia-headers') + +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + */ + +/** + * @param {InertiaRequest} req + * @param {string} component + * @returns {boolean} + */ module.exports = function isInertiaPartialRequest(req, component) { return req.get(PARTIAL_COMPONENT) === component } diff --git a/packages/inertia-sails/lib/helpers/is-inertia-request.js b/packages/inertia-sails/lib/helpers/is-inertia-request.js index 3eb10ecb..61e65d53 100644 --- a/packages/inertia-sails/lib/helpers/is-inertia-request.js +++ b/packages/inertia-sails/lib/helpers/is-inertia-request.js @@ -1,4 +1,13 @@ const { INERTIA } = require('./inertia-headers') + +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + */ + +/** + * @param {InertiaRequest} req + * @returns {any} + */ module.exports = function isInertiaRequest(req) { return req.get(INERTIA) } diff --git a/packages/inertia-sails/lib/helpers/request-context.js b/packages/inertia-sails/lib/helpers/request-context.js index dcf10046..f2abc235 100644 --- a/packages/inertia-sails/lib/helpers/request-context.js +++ b/packages/inertia-sails/lib/helpers/request-context.js @@ -14,12 +14,31 @@ * sharedLocals: {}, // Request-scoped locals for root EJS template * encryptHistory: null, // Request-scoped history encryption (null = use default) * clearHistory: false, // Request-scoped clear history flag + * preserveFragment: false, // Request-scoped fragment preservation flag * refreshOnceProps: [], // Props to force-refresh for this request * rootView: null // Request-scoped root view template (null = use default) * } */ const { AsyncLocalStorage } = require('async_hooks') +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaResponse} InertiaResponse + * @typedef {import('../types').InertiaProps} InertiaProps + * + * @typedef {Object} RequestContext + * @property {InertiaRequest} req + * @property {InertiaResponse} res + * @property {InertiaProps} sharedProps + * @property {InertiaProps} sharedLocals + * @property {boolean|null} encryptHistory + * @property {boolean} clearHistory + * @property {boolean} preserveFragment + * @property {string[]} refreshOnceProps + * @property {string|null} rootView + */ + +/** @type {AsyncLocalStorage} */ const requestContext = new AsyncLocalStorage() module.exports = { @@ -30,12 +49,13 @@ module.exports = { /** * Run a callback with request context stored - * @param {Object} req - The request object - * @param {Object} res - The response object - * @param {Function} callback - The callback to run + * @param {InertiaRequest} req - The request object + * @param {InertiaResponse} res - The response object + * @param {() => any} callback - The callback to run * @returns {*} - The result of the callback */ run(req, res, callback) { + /** @type {RequestContext} */ const context = { req, res, @@ -43,6 +63,7 @@ module.exports = { sharedLocals: {}, encryptHistory: null, clearHistory: false, + preserveFragment: false, refreshOnceProps: [], // Props to force-refresh for this request rootView: null // Request-scoped root view template } @@ -51,7 +72,7 @@ module.exports = { /** * Get the full context object - * @returns {Object|undefined} - The current context or undefined if not in context + * @returns {RequestContext|undefined} - The current context or undefined if not in context */ getContext() { return requestContext.getStore() @@ -59,7 +80,7 @@ module.exports = { /** * Get the current request from context - * @returns {Object|undefined} - The current request or undefined if not in context + * @returns {InertiaRequest|undefined} - The current request or undefined if not in context */ getRequest() { const context = requestContext.getStore() @@ -68,7 +89,7 @@ module.exports = { /** * Get the current response from context - * @returns {Object|undefined} - The current response or undefined if not in context + * @returns {InertiaResponse|undefined} - The current response or undefined if not in context */ getResponse() { const context = requestContext.getStore() @@ -77,7 +98,7 @@ module.exports = { /** * Get request-scoped shared props - * @returns {Object} - The shared props for this request + * @returns {InertiaProps} - The shared props for this request */ getSharedProps() { const context = requestContext.getStore() @@ -98,7 +119,7 @@ module.exports = { /** * Get request-scoped shared locals - * @returns {Object} - The shared locals for this request + * @returns {InertiaProps} - The shared locals for this request */ getSharedLocals() { const context = requestContext.getStore() @@ -157,6 +178,26 @@ module.exports = { } }, + /** + * Get request-scoped preserve fragment flag + * @returns {boolean} - Whether to preserve the URL fragment + */ + getPreserveFragment() { + const context = requestContext.getStore() + return context?.preserveFragment || false + }, + + /** + * Set request-scoped preserve fragment flag + * @param {boolean} preserve - Whether to preserve the URL fragment + */ + setPreserveFragment(preserve) { + const context = requestContext.getStore() + if (context) { + context.preserveFragment = preserve + } + }, + /** * Get the list of once props to force-refresh * @returns {string[]} - Array of prop keys to refresh diff --git a/packages/inertia-sails/lib/helpers/resolve-asset-version.js b/packages/inertia-sails/lib/helpers/resolve-asset-version.js new file mode 100644 index 00000000..51a27725 --- /dev/null +++ b/packages/inertia-sails/lib/helpers/resolve-asset-version.js @@ -0,0 +1,12 @@ +/** + * @typedef {import('../types').SailsLike} SailsLike + */ + +/** + * @param {SailsLike} sails + * @returns {any} + */ +module.exports = function resolveAssetVersion(sails) { + const assetVersion = sails.config.inertia.version + return typeof assetVersion === 'function' ? assetVersion() : assetVersion +} diff --git a/packages/inertia-sails/lib/helpers/resolve-validation-errors.js b/packages/inertia-sails/lib/helpers/resolve-validation-errors.js index 0e82759d..b61aa760 100644 --- a/packages/inertia-sails/lib/helpers/resolve-validation-errors.js +++ b/packages/inertia-sails/lib/helpers/resolve-validation-errors.js @@ -1,29 +1,36 @@ -// @ts-nocheck const { ERROR_BAG } = require('./inertia-headers') +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {Record} ValidationErrors + */ + /** * @module resolveValidationErrors * @description Resolves and formats validation errors from the session for Inertia responses. * - * @param {Object} req - The current HTTP request object. - * @returns {Object} An object representing the validation errors in the desired format. + * @param {InertiaRequest} req - The current HTTP request object. + * @returns {ValidationErrors} An object representing the validation errors in the desired format. */ module.exports = function resolveValidationErrors(req) { if (!req.session || !req.session.errors) { return {} } - const flashedErrors = req.session.errors + const flashedErrors = /** @type {Record} */ ( + req.session.errors + ) const collectedErrors = Object.keys(flashedErrors).reduce((result, bag) => { const errorsForBag = flashedErrors[bag] + /** @type {string[]} */ const mappedErrors = errorsForBag.map((error) => error.replace(/"([^"]+)"/, '$1') ) // Ensure that single errors are wrapped in an array result[bag] = mappedErrors.length > 1 ? mappedErrors : mappedErrors[0] return result - }, {}) + }, /** @type {ValidationErrors} */ ({})) const inertiaErrorBag = req.headers[ERROR_BAG] diff --git a/packages/inertia-sails/lib/location.js b/packages/inertia-sails/lib/location.js index 8af086ea..42343799 100644 --- a/packages/inertia-sails/lib/location.js +++ b/packages/inertia-sails/lib/location.js @@ -1,5 +1,16 @@ const { INERTIA, LOCATION } = require('./helpers/inertia-headers') +/** + * @typedef {import('./types').InertiaRequest} InertiaRequest + * @typedef {import('./types').InertiaResponse} InertiaResponse + */ + +/** + * @param {InertiaRequest} req + * @param {InertiaResponse} res + * @param {string} url + * @returns {any} + */ module.exports = function (req, res, url) { if (req.get(INERTIA)) { // If the method is PUT, PATCH, or DELETE, force a 303 redirect (so the next request is a GET) diff --git a/packages/inertia-sails/lib/middleware/inertia-middleware.js b/packages/inertia-sails/lib/middleware/inertia-middleware.js index 883ea8c1..00afc7de 100644 --- a/packages/inertia-sails/lib/middleware/inertia-middleware.js +++ b/packages/inertia-sails/lib/middleware/inertia-middleware.js @@ -1,6 +1,11 @@ const resolveValidationErrors = require('../helpers/resolve-validation-errors') const requestContext = require('../helpers/request-context') +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaResponse} InertiaResponse + */ + /** * Inertia middleware that handles validation errors. * @@ -11,8 +16,8 @@ const requestContext = require('../helpers/request-context') * This middleware handles: * - Validation errors from redirects (shared as 'errors' prop) * - * @param {Object} hook - The inertia-sails hook instance - * @returns {Function} Express/Sails middleware function + * @param {Record} hook - The inertia-sails hook instance + * @returns {(req: InertiaRequest, res: InertiaResponse, next: () => any) => any} Express/Sails middleware function */ function inertia(hook) { return function inertiaMiddleware(req, res, next) { diff --git a/packages/inertia-sails/lib/props/always-prop.js b/packages/inertia-sails/lib/props/always-prop.js index 67dce12a..b5f7a71b 100644 --- a/packages/inertia-sails/lib/props/always-prop.js +++ b/packages/inertia-sails/lib/props/always-prop.js @@ -1,5 +1,9 @@ const MergeableProp = require('./mergeable-prop') +/** + * @typedef {import('../types').PropCallback} PropCallback + */ + /** * AlwaysProp - A prop that is always resolved, even during partial reloads. * @@ -22,11 +26,11 @@ const MergeableProp = require('./mergeable-prop') module.exports = class AlwaysProp extends MergeableProp { /** * Create a new AlwaysProp instance - * @param {Function} callback - The callback function to resolve the prop value + * @param {PropCallback} callback - The callback function to resolve the prop value */ constructor(callback) { super() - /** @type {Function} */ + /** @type {PropCallback} */ this.callback = callback } } diff --git a/packages/inertia-sails/lib/props/defer-prop.js b/packages/inertia-sails/lib/props/defer-prop.js index 8161980d..5ebd72e0 100644 --- a/packages/inertia-sails/lib/props/defer-prop.js +++ b/packages/inertia-sails/lib/props/defer-prop.js @@ -1,6 +1,13 @@ const ignoreFirstLoadSymbol = require('../helpers/ignore-first-load-symbol') const MergeableProp = require('./mergeable-prop') +/** + * @typedef {import('../types').PropCallback} PropCallback + * + * @typedef {Object} DeferPropOptions + * @property {boolean} [rescue=false] - Whether callback failures should be rescued + */ + /** * DeferProp - A prop that loads after the initial page render. * @@ -30,17 +37,30 @@ const MergeableProp = require('./mergeable-prop') module.exports = class DeferProp extends MergeableProp { /** * Create a new DeferProp instance - * @param {Function} callback - The callback function to resolve the prop value - * @param {string} [group='default'] - The group name for loading props together + * @param {PropCallback} callback - The callback function to resolve the prop value + * @param {string|DeferPropOptions} [group='default'] - The group name for loading props together, or options + * @param {DeferPropOptions} [options] - Deferred prop options */ - constructor(callback, group) { + constructor(callback, group = 'default', options = {}) { super() - /** @type {Function} */ + if (typeof group === 'object' && group !== null) { + options = group + group = 'default' + } + const groupName = typeof group === 'string' ? group : 'default' + + /** @type {PropCallback} */ this.callback = callback /** @type {string} */ - this.group = group + this.group = groupName /** @type {boolean} */ - this[ignoreFirstLoadSymbol] = true + this.shouldRescue = options.rescue === true + Object.defineProperty(this, ignoreFirstLoadSymbol, { + value: true, + enumerable: true, + configurable: true, + writable: true + }) } /** @@ -50,4 +70,24 @@ module.exports = class DeferProp extends MergeableProp { getGroup() { return this.group } + + /** + * Rescue callback failures instead of failing the whole deferred response. + * The failed prop is omitted from props and reported in rescuedProps. + * + * @param {boolean} rescue - Whether callback failures should be rescued + * @returns {DeferProp} - Returns this for chaining + */ + rescue(rescue = true) { + this.shouldRescue = rescue + return this + } + + /** + * Get whether this deferred prop should rescue callback failures. + * @returns {boolean} - Whether callback failures should be rescued + */ + shouldRescueProp() { + return this.shouldRescue + } } diff --git a/packages/inertia-sails/lib/props/get-partial-data.js b/packages/inertia-sails/lib/props/get-partial-data.js index 2d2d7038..3ee44930 100644 --- a/packages/inertia-sails/lib/props/get-partial-data.js +++ b/packages/inertia-sails/lib/props/get-partial-data.js @@ -1,3 +1,12 @@ +/** + * @typedef {import('../types').InertiaProps} InertiaProps + */ + +/** + * @param {InertiaProps} props + * @param {string[]} [only] + * @returns {InertiaProps} + */ module.exports = function getPartialData(props, only = []) { return Object.assign({}, ...only.map((key) => ({ [key]: props[key] }))) } diff --git a/packages/inertia-sails/lib/props/merge-prop.js b/packages/inertia-sails/lib/props/merge-prop.js index a6424244..28f6f714 100644 --- a/packages/inertia-sails/lib/props/merge-prop.js +++ b/packages/inertia-sails/lib/props/merge-prop.js @@ -1,5 +1,9 @@ const MergeableProp = require('./mergeable-prop') +/** + * @typedef {import('../types').PropCallback} PropCallback + */ + /** * MergeProp - A prop that merges with existing data during partial reloads. * @@ -22,14 +26,13 @@ const MergeableProp = require('./mergeable-prop') module.exports = class MergeProp extends MergeableProp { /** * Create a new MergeProp instance - * @param {Function} callback - The callback function to resolve the prop value + * @param {PropCallback} callback - The callback function to resolve the prop value */ constructor(callback) { super() - /** @type {Function} */ + /** @type {PropCallback} */ this.callback = callback - /** @type {boolean} */ - this.shouldMerge = true + this.merge() } /** diff --git a/packages/inertia-sails/lib/props/merge-targets.js b/packages/inertia-sails/lib/props/merge-targets.js new file mode 100644 index 00000000..f8a1b85a --- /dev/null +++ b/packages/inertia-sails/lib/props/merge-targets.js @@ -0,0 +1,114 @@ +/** + * Utilities for describing how mergeable props should be merged by the client. + * + * @typedef {import('../types').MergeOperation} MergeOperation + * @typedef {import('../types').MergeOptions} MergeOptions + * @typedef {string|string[]|Record|null} MergeTargetInput + */ + +/** + * @returns {MergeOperation} + */ +function createDefaultMergeOperation() { + return { + direction: 'append', + path: null, + matchOn: null, + isDefault: true + } +} + +/** + * @param {MergeOptions|string|null|undefined} options + * @returns {MergeOptions} + */ +function normalizeMergeOptions(options) { + return typeof options === 'string' ? { matchOn: options } : options || {} +} + +/** + * @param {MergeTargetInput} paths + * @param {MergeOptions|string} [options] + * @returns {Array<{ path: string|null, matchOn: string|null }>} + */ +function normalizeMergeTargets(paths, options = {}) { + const normalizedOptions = normalizeMergeOptions(options) + + if (paths === null || paths === undefined) { + return [ + { + path: null, + matchOn: + typeof normalizedOptions.matchOn === 'string' + ? normalizedOptions.matchOn + : null + } + ] + } + + if (Array.isArray(paths)) { + return paths.map((path) => ({ + path: normalizePath(path), + matchOn: resolveTargetMatchOn(path, normalizedOptions) + })) + } + + if (typeof paths === 'object') { + return Object.entries(paths).map(([path, matchOn]) => ({ + path: normalizePath(path), + matchOn: matchOn || null + })) + } + + return [ + { + path: normalizePath(paths), + matchOn: resolveTargetMatchOn(paths, normalizedOptions) + } + ] +} + +/** + * @param {string} path + * @returns {string|null} + */ +function normalizePath(path) { + return path === '' ? null : path +} + +/** + * @param {string} path + * @param {MergeOptions} options + * @returns {string|null} + */ +function resolveTargetMatchOn(path, options) { + if (!options.matchOn) return null + if (typeof options.matchOn === 'object') return options.matchOn[path] || null + return options.matchOn +} + +/** + * @param {string} key + * @param {string|null} path + * @returns {string} + */ +function resolvePropPath(key, path) { + return path ? `${key}.${path}` : key +} + +/** + * @template T + * @param {T[]} values + * @returns {T[]} + */ +function unique(values) { + return [...new Set(values)] +} + +module.exports = { + createDefaultMergeOperation, + normalizeMergeOptions, + normalizeMergeTargets, + resolvePropPath, + unique +} diff --git a/packages/inertia-sails/lib/props/mergeable-prop.js b/packages/inertia-sails/lib/props/mergeable-prop.js index 3cc8267d..80510338 100644 --- a/packages/inertia-sails/lib/props/mergeable-prop.js +++ b/packages/inertia-sails/lib/props/mergeable-prop.js @@ -1,3 +1,15 @@ +const { + createDefaultMergeOperation, + normalizeMergeOptions, + normalizeMergeTargets +} = require('./merge-targets') + +/** + * @typedef {import('../types').MergeOperation} MergeOperation + * @typedef {import('../types').MergeOptions} MergeOptions + * @typedef {string|string[]|Record|null} MergeTargetInput + */ + /** * MergeableProp - Base class for props that can be merged during partial reloads. * @@ -12,6 +24,10 @@ module.exports = class MergeableProp { this.shouldMerge = false /** @type {boolean} - Whether to deep merge this prop */ this.shouldDeepMerge = false + /** @type {MergeOperation[]} */ + this.mergeOperations = [] + /** @type {string[]} - Paths to use when matching merge items */ + this.matchOnPaths = [] } /** @@ -21,9 +37,33 @@ module.exports = class MergeableProp { */ merge() { this.shouldMerge = true + this.shouldDeepMerge = false + if (this.mergeOperations.length === 0) { + this.mergeOperations.push(createDefaultMergeOperation()) + } return this } + /** + * Append this prop, or one or more nested paths, during partial reloads. + * @param {MergeTargetInput} [paths] - Path(s) to append, or a path-to-matchOn map + * @param {MergeOptions|string} [options] - Options, or a matchOn string + * @returns {MergeableProp} - Returns this for chaining + */ + append(paths = null, options = {}) { + return this._addMergeOperations('append', paths, options) + } + + /** + * Prepend this prop, or one or more nested paths, during partial reloads. + * @param {MergeTargetInput} [paths] - Path(s) to prepend, or a path-to-matchOn map + * @param {MergeOptions|string} [options] - Options, or a matchOn string + * @returns {MergeableProp} - Returns this for chaining + */ + prepend(paths = null, options = {}) { + return this._addMergeOperations('prepend', paths, options) + } + /** * Enable deep merging for this prop. * Recursively merges nested objects instead of replacing them. @@ -32,6 +72,53 @@ module.exports = class MergeableProp { deepMerge() { this.shouldMerge = true this.shouldDeepMerge = true + this.mergeOperations = [] return this } + + /** + * Configure one or more match-on paths for merge operations. + * @param {string|string[]} paths - Path(s) ending with the field to match on + * @returns {MergeableProp} - Returns this for chaining + */ + matchOn(paths) { + const normalizedPaths = Array.isArray(paths) ? paths : [paths] + normalizedPaths.filter(Boolean).forEach((path) => { + this.matchOnPaths.push(path) + }) + return this + } + + /** + * @param {'append'|'prepend'} direction + * @param {MergeTargetInput} paths + * @param {MergeOptions|string} options + * @returns {MergeableProp} + */ + _addMergeOperations(direction, paths, options) { + const normalizedOptions = normalizeMergeOptions(options) + + this.shouldMerge = true + this.shouldDeepMerge = false + this._clearDefaultMergeOperation() + + normalizeMergeTargets(paths, normalizedOptions).forEach((target) => { + this.mergeOperations.push({ + direction, + path: target.path, + matchOn: target.matchOn + }) + }) + + return this + } + + _clearDefaultMergeOperation() { + if ( + this.mergeOperations.length === 1 && + this.mergeOperations[0].isDefault + ) { + this.mergeOperations = [] + } + } } diff --git a/packages/inertia-sails/lib/props/once-prop.js b/packages/inertia-sails/lib/props/once-prop.js index 32905a16..da9e65ad 100644 --- a/packages/inertia-sails/lib/props/once-prop.js +++ b/packages/inertia-sails/lib/props/once-prop.js @@ -1,3 +1,7 @@ +/** + * @typedef {import('../types').PropCallback} PropCallback + */ + /** * OnceProp - A prop that is resolved only once and cached across navigations. * @@ -33,12 +37,16 @@ module.exports = class OnceProp { /** * Create a new OnceProp instance - * @param {Function} callback - The callback function to resolve the prop value + * @param {PropCallback} callback - The callback function to resolve the prop value */ constructor(callback) { + /** @type {PropCallback} */ this.callback = callback + /** @type {string|null} */ this._key = null + /** @type {number|null} */ this._ttl = null + /** @type {boolean} */ this._refresh = false } diff --git a/packages/inertia-sails/lib/props/optional-prop.js b/packages/inertia-sails/lib/props/optional-prop.js index 2be6c3fa..28f9f757 100644 --- a/packages/inertia-sails/lib/props/optional-prop.js +++ b/packages/inertia-sails/lib/props/optional-prop.js @@ -1,5 +1,9 @@ const ignoreFirstLoadSymbol = require('../helpers/ignore-first-load-symbol') +/** + * @typedef {import('../types').PropCallback} PropCallback + */ + /** * OptionalProp - A prop that is only evaluated when explicitly requested. * @@ -20,12 +24,16 @@ const ignoreFirstLoadSymbol = require('../helpers/ignore-first-load-symbol') module.exports = class OptionalProp { /** * Create a new OptionalProp instance - * @param {Function} callback - The callback function to resolve the prop value + * @param {PropCallback} callback - The callback function to resolve the prop value */ constructor(callback) { - /** @type {Function} */ + /** @type {PropCallback} */ this.callback = callback - /** @type {boolean} */ - this[ignoreFirstLoadSymbol] = true + Object.defineProperty(this, ignoreFirstLoadSymbol, { + value: true, + enumerable: true, + configurable: true, + writable: true + }) } } diff --git a/packages/inertia-sails/lib/props/pick-props-to-resolve.js b/packages/inertia-sails/lib/props/pick-props-to-resolve.js index 229f6a19..5bf7dc10 100644 --- a/packages/inertia-sails/lib/props/pick-props-to-resolve.js +++ b/packages/inertia-sails/lib/props/pick-props-to-resolve.js @@ -7,6 +7,17 @@ const resolveExceptProps = require('./resolve-except-props') const { filterOnceProps } = require('./resolve-once-props') const AlwaysProp = require('./always-prop') +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaProps} InertiaProps + */ + +/** + * @param {InertiaRequest} req + * @param {string} component + * @param {InertiaProps} [props] + * @returns {InertiaProps} + */ module.exports = function pickPropsToResolve(req, component, props = {}) { const isPartial = isInertiaPartialRequest(req, component) const isInertia = isInertiaRequest(req) @@ -15,7 +26,9 @@ module.exports = function pickPropsToResolve(req, component, props = {}) { if (!isPartial) { newProps = Object.fromEntries( Object.entries(props).filter(([_, value]) => { - if (value && value[ignoreFirstLoadSymbol]) return false + if (value && /** @type {any} */ (value)[ignoreFirstLoadSymbol]) { + return false + } return true }) ) diff --git a/packages/inertia-sails/lib/props/resolve-deferred-props.js b/packages/inertia-sails/lib/props/resolve-deferred-props.js index 63f2ca2d..525b3f9f 100644 --- a/packages/inertia-sails/lib/props/resolve-deferred-props.js +++ b/packages/inertia-sails/lib/props/resolve-deferred-props.js @@ -1,7 +1,23 @@ const isInertiaPartialRequest = require('../helpers/is-inertia-partial-request') const DeferProp = require('./defer-prop') + +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaProps} InertiaProps + * + * @typedef {Object} DeferredPropsMetadata + * @property {Record} [deferredProps] + */ + +/** + * @param {InertiaRequest} req + * @param {string} component + * @param {InertiaProps} pageProps + * @returns {DeferredPropsMetadata} + */ module.exports = function resolveDeferredProps(req, component, pageProps) { if (isInertiaPartialRequest(req, component)) return {} + /** @type {Record} */ const deferredProps = Object.entries(pageProps || {}) .filter(([_, value]) => value instanceof DeferProp) .map(([key, value]) => ({ key, group: value.getGroup() })) @@ -9,7 +25,7 @@ module.exports = function resolveDeferredProps(req, component, pageProps) { if (!groups[group]) groups[group] = [] groups[group].push(key) return groups - }, {}) + }, /** @type {Record} */ ({})) return Object.keys(deferredProps).length ? { deferredProps } : {} } diff --git a/packages/inertia-sails/lib/props/resolve-except-props.js b/packages/inertia-sails/lib/props/resolve-except-props.js index 0763b360..9bd7f565 100644 --- a/packages/inertia-sails/lib/props/resolve-except-props.js +++ b/packages/inertia-sails/lib/props/resolve-except-props.js @@ -1,4 +1,15 @@ const { PARTIAL_EXCEPT } = require('../helpers/inertia-headers') + +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaProps} InertiaProps + */ + +/** + * @param {InertiaRequest} req + * @param {InertiaProps} props + * @returns {InertiaProps} + */ module.exports = function resolveExceptProps(req, props) { const partialExceptHeader = req.get(PARTIAL_EXCEPT) const except = partialExceptHeader.split(',').filter(Boolean) diff --git a/packages/inertia-sails/lib/props/resolve-merge-props.js b/packages/inertia-sails/lib/props/resolve-merge-props.js index cccdace7..56cf200d 100644 --- a/packages/inertia-sails/lib/props/resolve-merge-props.js +++ b/packages/inertia-sails/lib/props/resolve-merge-props.js @@ -1,37 +1,98 @@ -const { RESET } = require('../helpers/inertia-headers') +const { + INFINITE_SCROLL_MERGE_INTENT, + RESET +} = require('../helpers/inertia-headers') const MergeableProp = require('./mergeable-prop') +const { resolvePropPath, unique } = require('./merge-targets') +const ScrollProp = require('./scroll-prop') + +/** + * @typedef {import('../types').InertiaProps} InertiaProps + * @typedef {{ get: (header: string) => any }} HeaderRequest + * + * @typedef {Object} MergePropsMetadata + * @property {string[]} [mergeProps] + * @property {string[]} [prependProps] + * @property {string[]} [deepMergeProps] + * @property {string[]} [matchPropsOn] + */ /** * Resolve merge props metadata for the page response. * Returns mergeProps and deepMergeProps arrays for the client. - * @param {Object} req - The request object - * @param {Object} pageProps - The page props - * @returns {Object} - Object with mergeProps and/or deepMergeProps arrays + * @param {HeaderRequest} req - The request object + * @param {InertiaProps} pageProps - The page props + * @returns {MergePropsMetadata} - Object with mergeProps and/or deepMergeProps arrays */ module.exports = function resolveMergeProps(req, pageProps) { const inertiaResetHeader = req.get(RESET) const resetProps = new Set(inertiaResetHeader?.split(',').filter(Boolean)) + const infiniteScrollMergeIntent = req.get(INFINITE_SCROLL_MERGE_INTENT) + + /** @type {string[]} */ + const mergeProps = [] + /** @type {string[]} */ + const prependProps = [] + /** @type {string[]} */ + const deepMergeProps = [] + /** @type {string[]} */ + const matchPropsOn = [] + + Object.entries(pageProps || {}).forEach(([key, value]) => { + if (!(value instanceof MergeableProp) || !value.shouldMerge) return + if (resetProps.has(key)) return + + if (value instanceof ScrollProp) { + const propPath = resolvePropPath(key, value.wrapper) + if (resetProps.has(propPath)) return + + if (infiniteScrollMergeIntent === 'prepend') { + prependProps.push(propPath) + } else { + mergeProps.push(propPath) + } + + if (value.matchOnPath) { + matchPropsOn.push(resolvePropPath(propPath, value.matchOnPath)) + } + + return + } + + if (value.shouldDeepMerge) { + deepMergeProps.push(key) + value.matchOnPaths.forEach((path) => { + matchPropsOn.push(resolvePropPath(key, path)) + }) + return + } + + value.mergeOperations.forEach((operation) => { + const propPath = resolvePropPath(key, operation.path) + if (resetProps.has(propPath)) return - const mergeableEntries = Object.entries(pageProps || {}).filter( - ([key, value]) => - value instanceof MergeableProp && - value.shouldMerge && - !resetProps.has(key) - ) + if (operation.direction === 'prepend') { + prependProps.push(propPath) + } else { + mergeProps.push(propPath) + } - // Props that should be shallow merged (appended) - const mergeProps = mergeableEntries - .filter(([_, value]) => !value.shouldDeepMerge) - .map(([key]) => key) + if (operation.matchOn) { + matchPropsOn.push(resolvePropPath(propPath, operation.matchOn)) + } + }) - // Props that should be deep merged - const deepMergeProps = mergeableEntries - .filter(([_, value]) => value.shouldDeepMerge) - .map(([key]) => key) + value.matchOnPaths.forEach((path) => { + matchPropsOn.push(resolvePropPath(key, path)) + }) + }) + /** @type {MergePropsMetadata} */ const result = {} - if (mergeProps.length) result.mergeProps = mergeProps - if (deepMergeProps.length) result.deepMergeProps = deepMergeProps + if (mergeProps.length) result.mergeProps = unique(mergeProps) + if (prependProps.length) result.prependProps = unique(prependProps) + if (deepMergeProps.length) result.deepMergeProps = unique(deepMergeProps) + if (matchPropsOn.length) result.matchPropsOn = unique(matchPropsOn) return result } diff --git a/packages/inertia-sails/lib/props/resolve-once-props.js b/packages/inertia-sails/lib/props/resolve-once-props.js index 2ad96ad5..3d4979e4 100644 --- a/packages/inertia-sails/lib/props/resolve-once-props.js +++ b/packages/inertia-sails/lib/props/resolve-once-props.js @@ -2,14 +2,22 @@ const OnceProp = require('./once-prop') const { EXCEPT_ONCE_PROPS } = require('../helpers/inertia-headers') const requestContext = require('../helpers/request-context') +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaProps} InertiaProps + * + * @typedef {Object} OncePropsMetadata + * @property {Record} [onceProps] + */ + /** * Get the list of once-props that the client already has cached. * These are sent via the X-Inertia-Except-Once-Props header. - * @param {Object} req - The request object + * @param {InertiaRequest} req - The request object * @returns {string[]} - Array of prop keys the client already has */ function getExceptOnceProps(req) { - const header = req.get(EXCEPT_ONCE_PROPS) || '' + const header = String(req.get(EXCEPT_ONCE_PROPS) || '') return header ? header .split(',') @@ -28,9 +36,9 @@ function getExceptOnceProps(req) { * - They are marked for refresh via sails.inertia.refreshOnce() * - The client doesn't have them cached * - * @param {Object} req - The request object - * @param {Object} props - The props object - * @returns {Object} - Filtered props with cached once-props removed + * @param {InertiaRequest} req - The request object + * @param {InertiaProps} props - The props object + * @returns {InertiaProps} - Filtered props with cached once-props removed */ function filterOnceProps(req, props) { const exceptOnceProps = getExceptOnceProps(req) @@ -40,6 +48,7 @@ function filterOnceProps(req, props) { return props } + /** @type {InertiaProps} */ const filtered = {} for (const [key, value] of Object.entries(props)) { // Keep the prop if it's not a OnceProp @@ -75,10 +84,11 @@ function filterOnceProps(req, props) { /** * Build the onceProps metadata for the page response. * This tells the client which props are "once" props and their expiration. - * @param {Object} props - The props object - * @returns {Object} - Object with onceProps key if any exist, empty object otherwise + * @param {InertiaProps} props - The props object + * @returns {OncePropsMetadata} - Object with onceProps key if any exist, empty object otherwise */ function resolveOncePropsMetadata(props) { + /** @type {Record} */ const onceProps = {} for (const [key, value] of Object.entries(props)) { diff --git a/packages/inertia-sails/lib/props/resolve-only-props.js b/packages/inertia-sails/lib/props/resolve-only-props.js index 3ec06ae8..4bd180f4 100644 --- a/packages/inertia-sails/lib/props/resolve-only-props.js +++ b/packages/inertia-sails/lib/props/resolve-only-props.js @@ -1,4 +1,10 @@ const { PARTIAL_DATA } = require('../helpers/inertia-headers') + +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaProps} InertiaProps + */ + /** * Extracts only the props specified by the partial header from the given props object. * @@ -6,13 +12,14 @@ const { PARTIAL_DATA } = require('../helpers/inertia-headers') * which should contain a comma-separated list of property keys. It then constructs and returns * a new object that includes only those keys from the provided props object. * - * @param {Object} req - The Express-style request object. It must have a `get` method to retrieve headers. - * @param {Object} props - The complete set of props. - * @returns {Object} An object containing only the properties whose keys were specified in the partial header. + * @param {InertiaRequest} req - The Express-style request object. It must have a `get` method to retrieve headers. + * @param {InertiaProps} props - The complete set of props. + * @returns {InertiaProps} An object containing only the properties whose keys were specified in the partial header. */ module.exports = function resolveOnlyProps(req, props) { const partialOnlyHeader = req.get(PARTIAL_DATA) const only = partialOnlyHeader.split(',').filter(Boolean) + /** @type {InertiaProps} */ let newProps = {} for (const key of only) newProps[key] = props[key] diff --git a/packages/inertia-sails/lib/props/resolve-page-props.js b/packages/inertia-sails/lib/props/resolve-page-props.js index e883c979..7cafa620 100644 --- a/packages/inertia-sails/lib/props/resolve-page-props.js +++ b/packages/inertia-sails/lib/props/resolve-page-props.js @@ -1,4 +1,64 @@ +const DeferProp = require('./defer-prop') const resolveProp = require('./resolve-prop') + +/** + * @typedef {import('../types').InertiaProps} InertiaProps + * @typedef {import('../types').ResolvedPageProps} ResolvedPageProps + * + * @typedef {((props?: InertiaProps) => Promise) & { + * withMetadata: (props?: InertiaProps) => Promise + * }} ResolvePageProps + */ + +/** + * @param {any} value + * @returns {boolean} + */ +function shouldRescueProp(value) { + return value instanceof DeferProp && value.shouldRescueProp() +} + +/** + * @param {InertiaProps} [props] + * @returns {Promise} + */ +async function resolvePagePropsWithMetadata(props = {}) { + const resolved = await Promise.all( + Object.entries(props).map(async ([key, value]) => { + try { + if (typeof value === 'function') { + const result = await value() + return { entry: await resolveProp(key, result) } + } + return { entry: await resolveProp(key, value) } + } catch (error) { + if (shouldRescueProp(value)) { + return { rescuedProp: key } + } + throw error + } + }) + ) + + /** @type {Array<[string, any]>} */ + const entries = [] + /** @type {string[]} */ + const rescuedProps = [] + + for (const result of resolved) { + if (result.rescuedProp) { + rescuedProps.push(result.rescuedProp) + continue + } + entries.push(result.entry) + } + + return { + props: Object.fromEntries(entries), + rescuedProps + } +} + /** * Resolve all page props. * @@ -7,18 +67,17 @@ const resolveProp = require('./resolve-prop') * Then, every property is passed to resolveProp() to see if it needs * any special handling. * - * @param {Object} [props={}] - An object containing page props. - * @returns {Promise} A promise that resolves to a new object with resolved props. + * @param {InertiaProps} [props={}] - An object containing page props. + * @returns {Promise} A promise that resolves to a new object with resolved props. */ -module.exports = async function resolvePageProps(props = {}) { - const entries = await Promise.all( - Object.entries(props).map(async ([key, value]) => { - if (typeof value === 'function') { - const result = await value() - return resolveProp(key, result) - } - return resolveProp(key, value) - }) - ) - return Object.fromEntries(entries) +async function resolvePageProps(props = {}) { + const resolved = await resolvePagePropsWithMetadata(props) + return resolved.props } + +/** @type {ResolvePageProps} */ +const exportedResolvePageProps = Object.assign(resolvePageProps, { + withMetadata: resolvePagePropsWithMetadata +}) + +module.exports = exportedResolvePageProps diff --git a/packages/inertia-sails/lib/props/resolve-scroll-props.js b/packages/inertia-sails/lib/props/resolve-scroll-props.js index bdf7cba3..b218713f 100644 --- a/packages/inertia-sails/lib/props/resolve-scroll-props.js +++ b/packages/inertia-sails/lib/props/resolve-scroll-props.js @@ -1,12 +1,25 @@ const ScrollProp = require('./scroll-prop') +/** + * @typedef {import('../types').InertiaProps} InertiaProps + * + * @typedef {Object} ScrollPropsMetadata + * @property {Record} [scrollProps] + */ + /** * Resolve scroll props metadata for the page response. * Extracts ScrollProp instances and builds the scrollProps object - * expected by Inertia.js v2's component. + * expected by Inertia's component. * - * @param {Object} pageProps - The page props - * @returns {Object} - Object with scrollProps if any exist + * @param {InertiaProps} pageProps - The page props + * @returns {ScrollPropsMetadata} - Object with scrollProps if any exist * * @example * // Input props with ScrollProp instance: @@ -26,6 +39,7 @@ const ScrollProp = require('./scroll-prop') * } */ module.exports = function resolveScrollProps(pageProps) { + /** @type {NonNullable} */ const scrollProps = {} for (const [key, value] of Object.entries(pageProps || {})) { diff --git a/packages/inertia-sails/lib/props/scroll-prop.js b/packages/inertia-sails/lib/props/scroll-prop.js index ce1a7506..865c3631 100644 --- a/packages/inertia-sails/lib/props/scroll-prop.js +++ b/packages/inertia-sails/lib/props/scroll-prop.js @@ -1,5 +1,17 @@ const MergeProp = require('./merge-prop') +/** + * @typedef {import('../types').PropCallback} PropCallback + * + * @typedef {Object} ScrollPropOptions + * @property {number} [page=0] - Current page index (0-based for Waterline) + * @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 + */ + /** * ScrollProp - Configures paginated data for infinite scrolling. * @@ -20,13 +32,8 @@ const MergeProp = require('./merge-prop') */ class ScrollProp extends MergeProp { /** - * @param {Function} callback - Callback returning the paginated data array - * @param {Object} [options] - Pagination options - * @param {number} [options.page=0] - Current page index (0-based for Waterline) - * @param {number} [options.perPage=10] - Items per page - * @param {number} [options.total=0] - Total number of items - * @param {string} [options.pageName='page'] - Query parameter name for pagination - * @param {string} [options.wrapper='data'] - Key to wrap the data in + * @param {PropCallback} callback - Callback returning the paginated data array + * @param {ScrollPropOptions} [options] - Pagination options */ constructor(callback, options = {}) { const { @@ -34,7 +41,8 @@ class ScrollProp extends MergeProp { perPage = 10, total = 0, pageName = 'page', - wrapper = 'data' + wrapper = 'data', + matchOn = null } = options // Calculate pagination metadata @@ -45,6 +53,7 @@ class ScrollProp extends MergeProp { const hasPreviousPage = currentPage > 1 // Wrap the callback to return structured data with metadata + /** @type {() => Promise} */ const wrappedCallback = async () => { const data = typeof callback === 'function' ? await callback() : callback @@ -66,13 +75,26 @@ class ScrollProp extends MergeProp { super(wrappedCallback) + // InfiniteScroll uses request headers to decide append vs prepend. + /** @type {import('../types').MergeOperation[]} */ + this.mergeOperations = [] + // Store metadata for potential access + /** @type {number} */ this.page = page + /** @type {number} */ this.perPage = perPage + /** @type {number} */ this.total = total + /** @type {string} */ this.pageName = pageName + /** @type {string} */ this.wrapper = wrapper + /** @type {string|null} */ + this.matchOnPath = matchOn + /** @type {number} */ this.totalPages = totalPages + /** @type {number} */ this.currentPage = currentPage } } diff --git a/packages/inertia-sails/lib/render.js b/packages/inertia-sails/lib/render.js index 719afbe1..2a63090f 100644 --- a/packages/inertia-sails/lib/render.js +++ b/packages/inertia-sails/lib/render.js @@ -2,7 +2,60 @@ const { encode } = require('querystring') const inertiaHeaders = require('./helpers/inertia-headers') const buildPageObject = require('./helpers/build-page-object') const requestContext = require('./helpers/request-context') +const resolveAssetVersion = require('./helpers/resolve-asset-version') +/** + * @typedef {import('./types').InertiaRequest} InertiaRequest + * @typedef {import('./types').InertiaResponse} InertiaResponse + * @typedef {import('./types').InertiaProps} InertiaProps + * + * @typedef {Object} RenderData + * @property {string} page + * @property {InertiaProps} [props] + * @property {InertiaProps} [locals] + */ + +/** + * @param {InertiaRequest} req + * @returns {string} + */ +function getRequestUrl(req) { + let url = req.url || req.originalUrl || '/' + const queryParams = req.query || {} + + if (req.method === 'GET' && Object.keys(queryParams).length) { + // Only append query params if the URL doesn't already contain them + // This prevents duplication when redirecting with query parameters + if (!url.includes('?')) { + url += `?${encode(queryParams)}` + } + } + + return url +} + +/** + * @param {InertiaRequest} req + * @param {any} currentVersion + * @returns {boolean} + */ +function hasAssetVersionMismatch(req, currentVersion) { + const requestVersion = req.get(inertiaHeaders.VERSION) + + if (req.method !== 'GET') return false + if (!req.get(inertiaHeaders.INERTIA)) return false + if (requestVersion === undefined || requestVersion === null) return false + if (currentVersion === undefined || currentVersion === null) return false + + return String(requestVersion) !== String(currentVersion) +} + +/** + * @param {InertiaRequest} req + * @param {InertiaResponse} res + * @param {RenderData} data + * @returns {Promise} + */ module.exports = async function render(req, res, data) { const sails = req._sails // Use request-scoped rootView if set, otherwise fall back to config @@ -15,20 +68,26 @@ module.exports = async function render(req, res, data) { ...data.locals } - let page = await buildPageObject(req, data.page, data.props) + const currentVersion = resolveAssetVersion(sails) + const requestUrl = getRequestUrl(req) - const queryParams = req.query - if (req.method === 'GET' && Object.keys(queryParams).length) { - // Only append query params if the URL doesn't already contain them - // This prevents duplication when redirecting with query parameters - if (!page.url.includes('?')) { - page.url += `?${encode(queryParams)}` - } + if (hasAssetVersionMismatch(req, currentVersion)) { + res.set('Vary', 'X-Inertia') + res.set(inertiaHeaders.LOCATION, requestUrl) + return res.status(409).end() } + let page = await buildPageObject( + /** @type {any} */ (req), + data.page, + data.props + ) + + page.url = requestUrl + if (req.get(inertiaHeaders.INERTIA)) { res.set(inertiaHeaders.INERTIA, true) - res.set('Vary', 'Accept') + res.set('Vary', 'X-Inertia') return res.json(page) } else { // Implements full page reload diff --git a/packages/inertia-sails/lib/responses/server-error.js b/packages/inertia-sails/lib/responses/server-error.js index c5ed3e09..a96f4929 100644 --- a/packages/inertia-sails/lib/responses/server-error.js +++ b/packages/inertia-sails/lib/responses/server-error.js @@ -1,3 +1,10 @@ +/** + * @typedef {import('../types').InertiaRequest} InertiaRequest + * @typedef {import('../types').InertiaResponse} InertiaResponse + * @typedef {import('../types').ErrorHtmlData} ErrorHtmlData + * @typedef {{ message?: string, stack?: string, name?: string }} ErrorLike + */ + /** * Server Error Response for Inertia.js * @@ -9,9 +16,9 @@ * in a modal overlay during development, allowing developers to see stack traces * without losing their page state. * - * @param {Object} req - Express/Sails request object - * @param {Object} res - Express/Sails response object - * @param {Object|Error} [error] - Optional error data or Error object + * @param {InertiaRequest} req - Express/Sails request object + * @param {InertiaResponse} res - Express/Sails response object + * @param {ErrorLike} [error] - Optional error data or Error object * @returns {*} - Response * * @example @@ -29,7 +36,7 @@ module.exports = function handleServerError(req, res, error) { const sails = req._sails const statusCode = 500 - const isInertiaRequest = req.header('X-Inertia') + const isInertiaRequest = req.header?.('X-Inertia') const isDevelopment = process.env.NODE_ENV !== 'production' // Log the error @@ -49,8 +56,8 @@ module.exports = function handleServerError(req, res, error) { errorName, errorMessage, errorStack, - url: req.url, - method: req.method + url: req.url || '/', + method: req.method || 'GET' }) res.status(statusCode) @@ -76,6 +83,10 @@ module.exports = function handleServerError(req, res, error) { { error: isDevelopment ? error : null }, + /** + * @param {Error|null} err + * @param {string} html + */ (err, html) => { if (err) { // If view doesn't exist, send a basic response @@ -91,6 +102,8 @@ module.exports = function handleServerError(req, res, error) { /** * Build an HTML error page for the Inertia modal + * @param {ErrorHtmlData} data + * @returns {string} */ function buildErrorHtml({ statusCode, @@ -270,6 +283,8 @@ function buildErrorHtml({ /** * Format stack trace with syntax highlighting + * @param {string} stack + * @returns {string} */ function formatStackTrace(stack) { return stack @@ -294,6 +309,8 @@ function formatStackTrace(stack) { /** * Escape HTML special characters + * @param {any} str + * @returns {string} */ function escapeHtml(str) { if (!str) return '' diff --git a/packages/inertia-sails/lib/types.js b/packages/inertia-sails/lib/types.js new file mode 100644 index 00000000..ddc88593 --- /dev/null +++ b/packages/inertia-sails/lib/types.js @@ -0,0 +1,68 @@ +/** + * Shared JSDoc typedefs for inertia-sails. + * + * These keep editor type-checking useful without turning the package into + * TypeScript. The shapes intentionally stay permissive because Sails request, + * response, and hook objects are extended by applications and other hooks. + * + * @typedef {Record} InertiaProps + * + * @typedef {Object} SailsLike + * @property {Record} config + * @property {{ info?: (...args: any[]) => void, warn?: (...args: any[]) => void, error?: (...args: any[]) => void }} [log] + * @property {Record} [inertia] + * @property {{ bind?: (...args: any[]) => any }} [router] + * @property {(event: string, callback: (...args: any[]) => any) => any} [on] + * + * @typedef {Object} InertiaRequest + * @property {SailsLike} _sails + * @property {string} [method] + * @property {string} [url] + * @property {string} [originalUrl] + * @property {Record} [query] + * @property {Record} [headers] + * @property {Record} [session] + * @property {boolean} [isSocket] + * @property {(name: string) => any} get + * @property {(name: string) => any} [header] + * @property {(key: string, value?: any) => any[]} [flash] + * + * @typedef {Object} InertiaResponse + * @property {(name: string, value: any) => InertiaResponse} [set] + * @property {(code: number) => InertiaResponse} [status] + * @property {(code: number) => any} [sendStatus] + * @property {(body?: any) => any} [send] + * @property {(body: any) => any} [json] + * @property {(...args: any[]) => any} [redirect] + * @property {(view: string, data?: any, callback?: (err: Error | null, html: string) => any) => any} [view] + * @property {(type: string) => InertiaResponse} [type] + * @property {() => any} [end] + * + * @typedef {() => any | Promise} PropCallback + * + * @typedef {Object} MergeOperation + * @property {'append'|'prepend'|string} direction + * @property {string|null} path + * @property {string|null} matchOn + * @property {boolean} [isDefault] + * + * @typedef {Object} MergeOptions + * @property {string|Record} [matchOn] + * + * @typedef {Object} ResolvedPageProps + * @property {InertiaProps} props + * @property {string[]} rescuedProps + * + * @typedef {Object} BadRequestData + * @property {Array>} [problems] + * + * @typedef {Object} ErrorHtmlData + * @property {number} statusCode + * @property {string} errorName + * @property {string} errorMessage + * @property {string} errorStack + * @property {string} url + * @property {string} method + */ + +module.exports = {} diff --git a/packages/inertia-sails/package.json b/packages/inertia-sails/package.json index aebb39c0..885327c7 100644 --- a/packages/inertia-sails/package.json +++ b/packages/inertia-sails/package.json @@ -1,6 +1,6 @@ { "name": "inertia-sails", - "version": "1.3.3", + "version": "1.4.0", "description": "The Sails adapter for Inertia.", "main": "index.js", "sails": { @@ -8,7 +8,8 @@ "hookName": "inertia" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "check:js": "tsc --allowJs --checkJs --noEmit --skipLibCheck --target ES2022 --module commonjs --noImplicitAny index.js test.js lib/**/*.js tests/**/*.js", + "test": "npm run check:js && node --test tests/*.test.js tests/**/*.test.js" }, "repository": { "type": "git", diff --git a/packages/inertia-sails/test.js b/packages/inertia-sails/test.js index a1e85232..91d77c50 100644 --- a/packages/inertia-sails/test.js +++ b/packages/inertia-sails/test.js @@ -24,11 +24,13 @@ const { INERTIA, VERSION } = require('./lib/helpers/inertia-headers') * @typedef {Object} InertiaPage * @property {string} component - The component name * @property {string} url - The current URL - * @property {Object} props - The page props - * @property {Object} [flash] - Flash data + * @property {Record} props - The page props + * @property {Record} [flash] - Flash data + * @property {boolean} [preserveFragment] - Whether URL fragments are preserved after redirects * @property {string[]} [mergeProps] - Props to merge * @property {string[]} [deepMergeProps] - Props to deep merge - * @property {Object} [deferredProps] - Deferred props by group + * @property {Record} [deferredProps] - Deferred props by group + * @property {string[]} [rescuedProps] - Deferred props rescued after callback failures */ /** @@ -37,6 +39,19 @@ const { INERTIA, VERSION } = require('./lib/helpers/inertia-headers') * @property {number} statusCode - HTTP status code */ +/** + * @typedef {Object} SailsRequestOptions + * @property {string} url - Request URL or verb/path pair + * @property {Record} [data] - Request data + * @property {Record} [headers] - Request headers + */ + +/** + * @typedef {Object} SailsTestApp + * @property {{ inertia?: { version?: any } }} config + * @property {(options: SailsRequestOptions, callback: (err: Error|null, response: SailsResponse, body: InertiaPage) => void) => void} request + */ + /** * InertiaTestResponse - Fluent assertions for Inertia page responses */ @@ -115,7 +130,7 @@ class InertiaTestResponse { /** * Assert props match expected values - * @param {Object} expected - Object with expected key-value pairs + * @param {Record} expected - Object with expected key-value pairs * @returns {InertiaTestResponse} - For chaining */ assertProps(expected) { @@ -135,7 +150,7 @@ class InertiaTestResponse { /** * Assert a prop value using a callback * @param {string} key - Prop key - * @param {Function} callback - Callback receiving the value, should throw if invalid + * @param {(value: any) => void} callback - Callback receiving the value, should throw if invalid * @returns {InertiaTestResponse} - For chaining */ assertProp(key, callback) { @@ -233,9 +248,35 @@ class InertiaTestResponse { return this } + /** + * Assert rescuedProps contains specific keys + * @param {string[]} keys - Expected rescued prop keys + * @returns {InertiaTestResponse} - For chaining + */ + assertRescuedProps(keys) { + const rescuedProps = this.page.rescuedProps || [] + for (const key of keys) { + if (!rescuedProps.includes(key)) { + throw new Error(`Expected "${key}" in rescuedProps`) + } + } + return this + } + + /** + * Assert preserveFragment metadata is enabled + * @returns {InertiaTestResponse} - For chaining + */ + assertPreserveFragment() { + if (this.page.preserveFragment !== true) { + throw new Error('Expected preserveFragment to be true') + } + return this + } + /** * Get the raw page object for custom assertions - * @returns {Object} - The Inertia page object + * @returns {InertiaPage} - The Inertia page object */ getPage() { return this.page @@ -243,7 +284,7 @@ class InertiaTestResponse { /** * Get the raw props for custom assertions - * @returns {Object} - The props object + * @returns {Record} - The props object */ getProps() { return this.page.props @@ -252,14 +293,14 @@ class InertiaTestResponse { /** * Helper to get nested values using dot notation * @private - * @param {Object} obj - The object to search + * @param {Record} obj - The object to search * @param {string} path - Dot-notation path * @returns {*} - The value at the path */ _getNestedValue(obj, path) { return path.split('.').reduce( /** - * @param {Object} current + * @param {any} current * @param {string} key */ (current, key) => { @@ -289,14 +330,14 @@ class InertiaTestResponse { /** * Create Inertia testing utilities for a Sails instance - * @param {Object} sails - The Sails application instance - * @returns {Object} - Testing utilities + * @param {SailsTestApp} sails - The Sails application instance + * @returns {Record} - Testing utilities */ module.exports = function createInertiaTestUtils(sails) { return { /** * Make an Inertia request and return a test response - * @param {string|Object} urlOrOptions - URL string or request options + * @param {string|SailsRequestOptions} urlOrOptions - URL string or request options * @returns {Promise} - Test response with assertions * @example * // Simple GET diff --git a/packages/inertia-sails/tests/helpers/build-page-object.test.js b/packages/inertia-sails/tests/helpers/build-page-object.test.js new file mode 100644 index 00000000..9a511104 --- /dev/null +++ b/packages/inertia-sails/tests/helpers/build-page-object.test.js @@ -0,0 +1,183 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const buildPageObject = require('../../lib/helpers/build-page-object') +const DeferProp = require('../../lib/props/defer-prop') +const { + INERTIA, + PARTIAL_COMPONENT, + PARTIAL_DATA +} = require('../../lib/helpers/inertia-headers') + +/** + * @param {Record} object + * @param {string} key + * @returns {boolean} + */ +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object, key) +} + +function createRequest({ + sharedProps = {}, + pageProps = {}, + clearHistory = false, + encryptHistory = false, + preserveFragment = false, + flash = {}, + headers = {}, + version = 'test-version', + url = '/dashboard' +} = {}) { + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) + ) + + return { + url, + /** + * @param {string} header + * @returns {any} + */ + get(header) { + return normalizedHeaders[header.toLowerCase()] + }, + _sails: { + config: { + inertia: { + version + } + }, + inertia: { + getShared() { + return sharedProps + }, + shouldClearHistory() { + return clearHistory + }, + shouldEncryptHistory() { + return encryptHistory + }, + consumePreserveFragment() { + return preserveFragment + }, + consumeFlash() { + return flash + } + } + }, + pageProps + } +} + +describe('buildPageObject', function () { + it('omits false history flags from the page object', async function () { + const req = createRequest() + const page = await buildPageObject(req, 'dashboard/index', {}) + + assert.equal(hasOwn(page, 'clearHistory'), false) + assert.equal(hasOwn(page, 'encryptHistory'), false) + }) + + it('includes true history flags in the page object', async function () { + const req = createRequest({ + clearHistory: true, + encryptHistory: true + }) + const page = await buildPageObject(req, 'dashboard/index', {}) + + assert.equal(page.clearHistory, true) + assert.equal(page.encryptHistory, true) + }) + + it('omits false preserveFragment from the page object', async function () { + const req = createRequest() + const page = await buildPageObject(req, 'dashboard/index', {}) + + assert.equal(hasOwn(page, 'preserveFragment'), false) + }) + + it('includes true preserveFragment in the page object', async function () { + const req = createRequest({ + preserveFragment: true + }) + const page = await buildPageObject(req, 'dashboard/index', {}) + + assert.equal(page.preserveFragment, true) + }) + + it('adds sharedProps metadata for shared prop keys', async function () { + const req = createRequest({ + sharedProps: { + auth: { user: { id: 1 } }, + app: { name: 'Boring Stack' } + } + }) + const page = await buildPageObject(req, 'dashboard/index', { + stats: { users: 10 } + }) + + assert.deepEqual(page.sharedProps, ['auth', 'app']) + assert.deepEqual(page.props.auth, { user: { id: 1 } }) + assert.deepEqual(page.props.stats, { users: 10 }) + }) + + it('keeps sharedProps metadata when page props override a shared key', async function () { + const req = createRequest({ + sharedProps: { + auth: { user: { id: 1 } } + } + }) + const page = await buildPageObject(req, 'dashboard/index', { + auth: { user: { id: 2 } } + }) + + assert.deepEqual(page.sharedProps, ['auth']) + assert.deepEqual(page.props.auth, { user: { id: 2 } }) + }) + + it('omits sharedProps when no shared props are present', async function () { + const req = createRequest() + const page = await buildPageObject(req, 'dashboard/index', {}) + + assert.equal(hasOwn(page, 'sharedProps'), false) + }) + + it('includes rescuedProps when a rescuable deferred prop fails', async function () { + const req = createRequest({ + headers: { + [INERTIA]: 'true', + [PARTIAL_COMPONENT]: 'dashboard/index', + [PARTIAL_DATA]: 'analytics' + } + }) + const page = await buildPageObject(req, 'dashboard/index', { + analytics: new DeferProp(async () => { + throw new Error('Analytics service unavailable') + }).rescue() + }) + + assert.deepEqual(page.props, {}) + assert.deepEqual(page.rescuedProps, ['analytics']) + }) + + it('omits merge metadata for rescued deferred props', async function () { + const req = createRequest({ + headers: { + [INERTIA]: 'true', + [PARTIAL_COMPONENT]: 'dashboard/index', + [PARTIAL_DATA]: 'analytics' + } + }) + const analytics = new DeferProp(async () => { + throw new Error('Analytics service unavailable') + }) + analytics.append('data', 'id') + analytics.rescue() + + const page = await buildPageObject(req, 'dashboard/index', { analytics }) + + assert.deepEqual(page.rescuedProps, ['analytics']) + assert.equal(hasOwn(page, 'mergeProps'), false) + assert.equal(hasOwn(page, 'matchPropsOn'), false) + }) +}) diff --git a/packages/inertia-sails/tests/helpers/preserve-fragment.test.js b/packages/inertia-sails/tests/helpers/preserve-fragment.test.js new file mode 100644 index 00000000..0ddec7a1 --- /dev/null +++ b/packages/inertia-sails/tests/helpers/preserve-fragment.test.js @@ -0,0 +1,97 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const defineInertiaHook = require('../..') +const requestContext = require('../../lib/helpers/request-context') + +/** + * @param {Record} object + * @param {string} key + * @returns {boolean} + */ +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object, key) +} + +function createHook() { + /** @type {Record} */ + const sails = { + config: { + inertia: { + history: { + encrypt: false + } + } + }, + log: { + warn() {} + } + } + const hook = defineInertiaHook(/** @type {any} */ (sails)) + sails.inertia = hook + return hook +} + +/** + * @param {Record} req + * @param {() => Promise} callback + * @returns {Promise} + */ +function runWithContext(req, callback) { + return new Promise((resolve, reject) => { + requestContext.run( + /** @type {any} */ (req), + /** @type {any} */ ({}), + async () => { + try { + resolve(await callback()) + } catch (error) { + reject(error) + } + } + ) + }) +} + +describe('preserveFragment', function () { + it('stores preserveFragment in request context and session', async function () { + const hook = createHook() + /** @type {{ session: Record }} */ + const req = { session: {} } + + await runWithContext(req, async () => { + hook.preserveFragment() + + assert.equal(requestContext.getPreserveFragment(), true) + assert.equal(req.session._inertiaPreserveFragment, true) + }) + }) + + it('consumes and clears session-backed preserveFragment metadata', async function () { + const hook = createHook() + const req = { + session: { + _inertiaPreserveFragment: true + } + } + + assert.equal(hook.consumePreserveFragment(req), true) + assert.equal(hasOwn(req.session, '_inertiaPreserveFragment'), false) + }) + + it('can clear preserveFragment before it is consumed', async function () { + const hook = createHook() + const req = { + session: { + _inertiaPreserveFragment: true + } + } + + await runWithContext(req, async () => { + hook.preserveFragment(false) + + assert.equal(requestContext.getPreserveFragment(), false) + assert.equal(hasOwn(req.session, '_inertiaPreserveFragment'), false) + assert.equal(hook.consumePreserveFragment(req), false) + }) + }) +}) diff --git a/packages/inertia-sails/tests/props/merge-targets.test.js b/packages/inertia-sails/tests/props/merge-targets.test.js new file mode 100644 index 00000000..43edae5b --- /dev/null +++ b/packages/inertia-sails/tests/props/merge-targets.test.js @@ -0,0 +1,74 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const { + createDefaultMergeOperation, + normalizeMergeOptions, + normalizeMergeTargets, + resolvePropPath, + unique +} = require('../../lib/props/merge-targets') + +describe('merge-targets', function () { + it('creates the default root append operation', function () { + assert.deepEqual(createDefaultMergeOperation(), { + direction: 'append', + path: null, + matchOn: null, + isDefault: true + }) + }) + + it('normalizes string options as match-on metadata', function () { + assert.deepEqual(normalizeMergeOptions('id'), { matchOn: 'id' }) + }) + + it('normalizes root targets', function () { + assert.deepEqual(normalizeMergeTargets(null, { matchOn: 'id' }), [ + { + path: null, + matchOn: 'id' + } + ]) + }) + + it('normalizes array targets with shared match-on metadata', function () { + assert.deepEqual(normalizeMergeTargets(['users', 'messages'], 'id'), [ + { + path: 'users', + matchOn: 'id' + }, + { + path: 'messages', + matchOn: 'id' + } + ]) + }) + + it('normalizes object targets as path-to-match maps', function () { + assert.deepEqual( + normalizeMergeTargets({ + 'users.data': 'id', + messages: 'uuid' + }), + [ + { + path: 'users.data', + matchOn: 'id' + }, + { + path: 'messages', + matchOn: 'uuid' + } + ] + ) + }) + + it('resolves prop paths and unique values', function () { + assert.equal(resolvePropPath('users', 'data'), 'users.data') + assert.equal(resolvePropPath('users', null), 'users') + assert.deepEqual(unique(['users', 'users', 'messages']), [ + 'users', + 'messages' + ]) + }) +}) diff --git a/packages/inertia-sails/tests/props/resolve-merge-props.test.js b/packages/inertia-sails/tests/props/resolve-merge-props.test.js new file mode 100644 index 00000000..1bc453f9 --- /dev/null +++ b/packages/inertia-sails/tests/props/resolve-merge-props.test.js @@ -0,0 +1,151 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const resolveMergeProps = require('../../lib/props/resolve-merge-props') +const MergeProp = require('../../lib/props/merge-prop') +const DeferProp = require('../../lib/props/defer-prop') +const ScrollProp = require('../../lib/props/scroll-prop') +const { + INFINITE_SCROLL_MERGE_INTENT, + RESET +} = require('../../lib/helpers/inertia-headers') + +/** + * @param {Record} [headers] + * @returns {{ get: (header: string) => any }} + */ +function createRequest(headers = {}) { + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) + ) + + return { + get(header) { + return normalizedHeaders[header.toLowerCase()] + } + } +} + +describe('resolveMergeProps', function () { + it('marks merge props as appendable root props by default', function () { + const result = resolveMergeProps(createRequest(), { + notifications: new MergeProp(() => []) + }) + + assert.deepEqual(result, { + mergeProps: ['notifications'] + }) + }) + + it('supports root-level prepend props', function () { + const result = resolveMergeProps(createRequest(), { + notifications: new MergeProp(() => []).prepend() + }) + + assert.deepEqual(result, { + prependProps: ['notifications'] + }) + }) + + it('supports nested append props with match-on metadata', function () { + const result = resolveMergeProps(createRequest(), { + users: new MergeProp(() => ({})).append('data', { matchOn: 'id' }) + }) + + assert.deepEqual(result, { + mergeProps: ['users.data'], + matchPropsOn: ['users.data.id'] + }) + }) + + it('supports several nested append and prepend paths', function () { + const result = resolveMergeProps(createRequest(), { + dashboard: new MergeProp(() => ({})) + .append({ + 'users.data': 'id', + messages: 'uuid' + }) + .prepend('announcements') + }) + + assert.deepEqual(result, { + mergeProps: ['dashboard.users.data', 'dashboard.messages'], + prependProps: ['dashboard.announcements'], + matchPropsOn: ['dashboard.users.data.id', 'dashboard.messages.uuid'] + }) + }) + + it('supports deep merge props with match-on metadata', function () { + const result = resolveMergeProps(createRequest(), { + chat: new MergeProp(() => ({})).deepMerge().matchOn('messages.id') + }) + + assert.deepEqual(result, { + deepMergeProps: ['chat'], + matchPropsOn: ['chat.messages.id'] + }) + }) + + it('supports merge metadata on deferred props', function () { + const result = resolveMergeProps(createRequest(), { + results: new DeferProp(() => ({}), 'default').append('data', 'id') + }) + + assert.deepEqual(result, { + mergeProps: ['results.data'], + matchPropsOn: ['results.data.id'] + }) + }) + + it('appends infinite scroll data by wrapper path by default', function () { + const result = resolveMergeProps(createRequest(), { + users: new ScrollProp(() => [], { page: 0, perPage: 10, total: 25 }) + }) + + assert.deepEqual(result, { + mergeProps: ['users.data'] + }) + }) + + it('prepends infinite scroll data when requested by the client', function () { + const result = resolveMergeProps( + createRequest({ + [INFINITE_SCROLL_MERGE_INTENT]: 'prepend' + }), + { + users: new ScrollProp(() => [], { page: 1, perPage: 10, total: 25 }) + } + ) + + assert.deepEqual(result, { + prependProps: ['users.data'] + }) + }) + + it('supports custom infinite scroll wrappers and match-on metadata', function () { + const result = resolveMergeProps(createRequest(), { + feed: new ScrollProp(() => [], { + wrapper: 'items', + matchOn: 'id' + }) + }) + + assert.deepEqual(result, { + mergeProps: ['feed.items'], + matchPropsOn: ['feed.items.id'] + }) + }) + + it('skips merge metadata for reset props', function () { + const result = resolveMergeProps( + createRequest({ + [RESET]: 'results,notifications.data' + }), + { + results: new MergeProp(() => []), + notifications: new MergeProp(() => ({})).append('data', 'id') + } + ) + + assert.deepEqual(result, {}) + }) +}) diff --git a/packages/inertia-sails/tests/props/resolve-page-props.test.js b/packages/inertia-sails/tests/props/resolve-page-props.test.js new file mode 100644 index 00000000..0fbf0b18 --- /dev/null +++ b/packages/inertia-sails/tests/props/resolve-page-props.test.js @@ -0,0 +1,70 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const resolvePageProps = require('../../lib/props/resolve-page-props') +const DeferProp = require('../../lib/props/defer-prop') + +describe('resolvePageProps', function () { + it('keeps the existing resolved props return shape', async function () { + const props = await resolvePageProps({ + user: () => ({ id: 1 }), + count: 3 + }) + + assert.deepEqual(props, { + user: { id: 1 }, + count: 3 + }) + }) + + it('rescues opt-in deferred prop failures', async function () { + const result = await resolvePageProps.withMetadata({ + user: { id: 1 }, + analytics: new DeferProp(async () => { + throw new Error('Analytics service unavailable') + }).rescue() + }) + + assert.deepEqual(result.props, { + user: { id: 1 } + }) + assert.deepEqual(result.rescuedProps, ['analytics']) + }) + + it('rescues deferred props configured with rescue option', async function () { + const result = await resolvePageProps.withMetadata({ + permissions: new DeferProp( + async () => { + throw new Error('Permissions service unavailable') + }, + { rescue: true } + ) + }) + + assert.deepEqual(result.props, {}) + assert.deepEqual(result.rescuedProps, ['permissions']) + }) + + it('throws deferred prop failures unless rescue is enabled', async function () { + await assert.rejects( + () => + resolvePageProps.withMetadata({ + analytics: new DeferProp(async () => { + throw new Error('Analytics service unavailable') + }) + }), + /Analytics service unavailable/ + ) + }) + + it('throws normal prop callback failures', async function () { + await assert.rejects( + () => + resolvePageProps.withMetadata({ + analytics: async () => { + throw new Error('Analytics service unavailable') + } + }), + /Analytics service unavailable/ + ) + }) +}) diff --git a/packages/inertia-sails/tests/props/resolve-scroll-props.test.js b/packages/inertia-sails/tests/props/resolve-scroll-props.test.js new file mode 100644 index 00000000..a4c2b32e --- /dev/null +++ b/packages/inertia-sails/tests/props/resolve-scroll-props.test.js @@ -0,0 +1,51 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const resolveScrollProps = require('../../lib/props/resolve-scroll-props') +const ScrollProp = require('../../lib/props/scroll-prop') + +describe('resolveScrollProps', function () { + it('emits Inertia scroll metadata for scroll props', function () { + const result = resolveScrollProps({ + users: new ScrollProp(() => [], { + page: 1, + perPage: 10, + total: 25 + }) + }) + + assert.deepEqual(result, { + scrollProps: { + users: { + pageName: 'page', + currentPage: 2, + previousPage: 1, + nextPage: 3, + reset: false + } + } + }) + }) + + it('supports custom page names', function () { + const result = resolveScrollProps({ + orders: new ScrollProp(() => [], { + page: 0, + pageName: 'orders', + perPage: 15, + total: 20 + }) + }) + + assert.deepEqual(result, { + scrollProps: { + orders: { + pageName: 'orders', + currentPage: 1, + previousPage: null, + nextPage: 2, + reset: false + } + } + }) + }) +}) diff --git a/packages/inertia-sails/tests/render.test.js b/packages/inertia-sails/tests/render.test.js new file mode 100644 index 00000000..8ed3436b --- /dev/null +++ b/packages/inertia-sails/tests/render.test.js @@ -0,0 +1,197 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const render = require('../lib/render') +const { INERTIA, VERSION, LOCATION } = require('../lib/helpers/inertia-headers') + +/** + * @param {Object} [options] + * @param {string} [options.method] + * @param {string} [options.url] + * @param {Record} [options.query] + * @param {Record} [options.headers] + * @param {string} [options.version] + * @param {Record} [options.sharedProps] + * @param {Record} [options.locals] + * @returns {any} + */ +function createRequest({ + method = 'GET', + url = '/dashboard', + query = {}, + headers = {}, + version = 'current-version', + sharedProps = {}, + locals = {} +} = {}) { + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) + ) + + return { + method, + url, + query, + /** + * @param {string} header + * @returns {any} + */ + get(header) { + return normalizedHeaders[header.toLowerCase()] + }, + _sails: { + config: { + inertia: { + rootView: 'app', + version + } + }, + inertia: { + getLocals() { + return locals + }, + getShared() { + return sharedProps + }, + shouldClearHistory() { + return false + }, + shouldEncryptHistory() { + return false + }, + consumePreserveFragment() { + return false + }, + consumeFlash() { + return {} + } + } + } + } +} + +function createResponse() { + /** @type {Record} */ + const responseHeaders = {} + + return { + headers: responseHeaders, + statusCode: /** @type {number|null} */ (null), + body: /** @type {any} */ (null), + viewName: /** @type {string|null} */ (null), + viewData: /** @type {any} */ (null), + ended: false, + /** + * @param {string} header + * @param {any} value + * @returns {any} + */ + set(header, value) { + this.headers[header] = value + return this + }, + /** + * @param {number} statusCode + * @returns {any} + */ + status(statusCode) { + this.statusCode = statusCode + return this + }, + end() { + this.ended = true + return this + }, + /** + * @param {any} body + * @returns {any} + */ + json(body) { + this.body = body + return this + }, + /** + * @param {string} viewName + * @param {any} viewData + * @returns {any} + */ + view(viewName, viewData) { + this.viewName = viewName + this.viewData = viewData + return this + } + } +} + +describe('render', function () { + it('returns a 409 location response on GET asset version mismatch', async function () { + const req = createRequest({ + url: '/dashboard', + query: { tab: 'billing' }, + headers: { + [INERTIA]: 'true', + [VERSION]: 'old-version' + } + }) + const res = createResponse() + let resolvedProps = false + + await render(req, res, { + page: 'dashboard/index', + props: { + expensive: async () => { + resolvedProps = true + return 'should not resolve' + } + } + }) + + assert.equal(res.statusCode, 409) + assert.equal(res.ended, true) + assert.equal(res.headers.Vary, 'X-Inertia') + assert.equal(res.headers[LOCATION], '/dashboard?tab=billing') + assert.equal(resolvedProps, false) + }) + + it('does not return a 409 location response for non-GET mismatches', async function () { + const req = createRequest({ + method: 'POST', + headers: { + [INERTIA]: 'true', + [VERSION]: 'old-version' + } + }) + const res = createResponse() + + await render(req, res, { + page: 'dashboard/index', + props: { + saved: true + } + }) + + assert.equal(res.statusCode, null) + assert.equal(res.headers[LOCATION], undefined) + assert.equal(res.body.component, 'dashboard/index') + }) + + it('uses the v3 Vary header for Inertia JSON responses', async function () { + const req = createRequest({ + headers: { + [INERTIA]: 'true', + [VERSION]: 'current-version' + } + }) + const res = createResponse() + + await render(req, res, { + page: 'dashboard/index', + props: { + stats: { users: 10 } + } + }) + + assert.equal(res.headers[INERTIA], true) + assert.equal(res.headers.Vary, 'X-Inertia') + assert.deepEqual(res.body.props.stats, { users: 10 }) + }) +}) diff --git a/templates/ascent-react/assets/js/pages/billing/pricing.jsx b/templates/ascent-react/assets/js/pages/billing/pricing.jsx index 088e6238..a282c984 100644 --- a/templates/ascent-react/assets/js/pages/billing/pricing.jsx +++ b/templates/ascent-react/assets/js/pages/billing/pricing.jsx @@ -2,7 +2,7 @@ import { Head, Link } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout.jsx' import { useState } from 'react' -Pricing.layout = (page) => +Pricing.layout = AppLayout export default function Pricing({ plans }) { const [billingCycle, setBillingCycle] = useState('monthly') diff --git a/templates/ascent-react/assets/js/pages/blog.jsx b/templates/ascent-react/assets/js/pages/blog.jsx index 9334a4dc..18025c6f 100644 --- a/templates/ascent-react/assets/js/pages/blog.jsx +++ b/templates/ascent-react/assets/js/pages/blog.jsx @@ -1,7 +1,7 @@ import { Head, Link } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout.jsx' -Blog.layout = (page) => +Blog.layout = AppLayout export default function Blog({ appName, blogPosts }) { return ( diff --git a/templates/ascent-react/assets/js/pages/dashboard/index.jsx b/templates/ascent-react/assets/js/pages/dashboard/index.jsx index ee3e3043..bf1c2b14 100644 --- a/templates/ascent-react/assets/js/pages/dashboard/index.jsx +++ b/templates/ascent-react/assets/js/pages/dashboard/index.jsx @@ -1,11 +1,7 @@ import { Link, Head, usePage } from '@inertiajs/react' import DashboardLayout from '@/layouts/DashboardLayout.jsx' -Dashboard.layout = (page) => ( - - {page} - -) +Dashboard.layout = [DashboardLayout, { title: 'Dashboard', maxWidth: 'wide' }] export default function Dashboard() { const page = usePage() const loggedInUser = page.props.loggedInUser diff --git a/templates/ascent-react/assets/js/pages/features.jsx b/templates/ascent-react/assets/js/pages/features.jsx index ef178135..c6fdd0b2 100644 --- a/templates/ascent-react/assets/js/pages/features.jsx +++ b/templates/ascent-react/assets/js/pages/features.jsx @@ -1,7 +1,7 @@ import { Head, Link } from '@inertiajs/react' import AppLayout from '@/layouts/AppLayout.jsx' -Features.layout = (page) => +Features.layout = AppLayout export default function Features() { return ( <> diff --git a/templates/ascent-react/assets/js/pages/index.jsx b/templates/ascent-react/assets/js/pages/index.jsx index 0c62d1e6..5a6cbac5 100644 --- a/templates/ascent-react/assets/js/pages/index.jsx +++ b/templates/ascent-react/assets/js/pages/index.jsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { Message } from 'primereact/message' import '~/css/homepage.css' -Index.layout = (page) => +Index.layout = AppLayout export default function Index() { const [isWaitlistActive] = useState(true) const [shouldShake, setShouldShake] = useState(false) diff --git a/templates/ascent-react/assets/js/pages/settings/billing.jsx b/templates/ascent-react/assets/js/pages/settings/billing.jsx index fe7f6447..88ac4029 100644 --- a/templates/ascent-react/assets/js/pages/settings/billing.jsx +++ b/templates/ascent-react/assets/js/pages/settings/billing.jsx @@ -9,11 +9,10 @@ import { ProgressBar } from 'primereact/progressbar' import DashboardLayout from '@/layouts/DashboardLayout' import SettingsLayout from '@/layouts/SettingsLayout.jsx' -BillingSettings.layout = (page) => ( - - {page} - -) +BillingSettings.layout = [ + DashboardLayout, + { title: 'Billing', maxWidth: 'narrow' } +] export default function BillingSettings({ subscription, plans }) { const isSubscribed = !!subscription diff --git a/templates/ascent-react/assets/js/pages/settings/profile.jsx b/templates/ascent-react/assets/js/pages/settings/profile.jsx index 746461ff..81051e6a 100644 --- a/templates/ascent-react/assets/js/pages/settings/profile.jsx +++ b/templates/ascent-react/assets/js/pages/settings/profile.jsx @@ -12,11 +12,10 @@ import { ConfirmDialog } from 'primereact/confirmdialog' import { confirmDialog } from 'primereact/confirmdialog' import ImageUpload from '@/components/ImageUpload' -ProfileSettings.layout = (page) => ( - - {page} - -) +ProfileSettings.layout = [ + DashboardLayout, + { title: 'Profile', maxWidth: 'narrow' } +] export default function ProfileSettings() { const loggedInUser = usePage().props.loggedInUser diff --git a/templates/ascent-react/assets/js/pages/settings/security.jsx b/templates/ascent-react/assets/js/pages/settings/security.jsx index 55f7eb00..9371574e 100644 --- a/templates/ascent-react/assets/js/pages/settings/security.jsx +++ b/templates/ascent-react/assets/js/pages/settings/security.jsx @@ -15,11 +15,10 @@ import BackupCodesModal from '@/components/BackupCodesModal.jsx' import EmailTwoFactorSetupModal from '@/components/EmailTwoFactorSetupModal.jsx' import ManagePasskeysModal from '@/components/ManagePasskeysModal.jsx' -SecuritySettings.layout = (page) => ( - - {page} - -) +SecuritySettings.layout = [ + DashboardLayout, + { title: 'Security', maxWidth: 'narrow' } +] export default function SecuritySettings({ loggedInUser, diff --git a/templates/ascent-react/assets/js/pages/settings/team.jsx b/templates/ascent-react/assets/js/pages/settings/team.jsx index 6af15dc2..7ce8c861 100644 --- a/templates/ascent-react/assets/js/pages/settings/team.jsx +++ b/templates/ascent-react/assets/js/pages/settings/team.jsx @@ -18,11 +18,7 @@ import { Dropdown } from 'primereact/dropdown' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import ImageUpload from '@/components/ImageUpload' -TeamSettings.layout = (page) => ( - - {page} - -) +TeamSettings.layout = [DashboardLayout, { title: 'Team', maxWidth: 'narrow' }] export default function TeamSettings({ team, diff --git a/templates/ascent-react/package-lock.json b/templates/ascent-react/package-lock.json index ed3aaf10..fd0fa7a5 100644 --- a/templates/ascent-react/package-lock.json +++ b/templates/ascent-react/package-lock.json @@ -8,14 +8,15 @@ "name": "ascent-react", "version": "0.0.0", "dependencies": { - "@inertiajs/react": "^2.2.15", + "@inertiajs/react": "^3.1.1", "@sails-pay/lemonsqueezy": "^0.0.2", "@sailshq/connect-redis": "^6.1.3", "@sailshq/lodash": "^3.10.7", "@sailshq/socket.io-redis": "^6.1.2", "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", - "inertia-sails": "^1.1.0", + "axios": "^1.16.1", + "inertia-sails": "^1.4.0", "nodemailer": "^7.0.10", "primeicons": "^7.0.0", "primereact": "^10.9.7", @@ -49,6 +50,7 @@ "sails-hook-shipwright": "^1.1.0", "sails.io.js": "^1.2.1", "socket.io-client": "^4.8.1", + "sounding": "^0.0.1", "tailwindcss": "^3.4.18" }, "engines": { @@ -118,29 +120,37 @@ "license": "MIT" }, "node_modules/@inertiajs/core": { - "version": "2.2.15", - "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.2.15.tgz", - "integrity": "sha512-0hj2oBWzj2Z2+UMTrqBMrRrlgoPZsqru7E9FEiR+zkOTywhuV0izJi/rAbmGBIxPAz9v3zMN/wrAy4293xBZoQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.1.1.tgz", + "integrity": "sha512-l8NfuI6xaeLcSTUH67fk/RPKGr5Pm+oyeWJy6mBfWxHIiNpMctPQtha5/NLQ+RqGekJdmJ7cHH8l3JDkvoPXYg==", "license": "MIT", "dependencies": { - "@types/lodash-es": "^4.17.12", - "axios": "^1.12.2", - "lodash-es": "^4.17.21", - "qs": "^6.14.0" + "@jridgewell/trace-mapping": "^0.3.31", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "axios": "^1.15.2" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } } }, "node_modules/@inertiajs/react": { - "version": "2.2.15", - "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.2.15.tgz", - "integrity": "sha512-b6BaGtW18TqyADr/dwTSe5FHYTo6iCGkTmVLxJyyIUqKE08YXXzxfFPWbjhLsfP5SDvpg/uqlVVfMP8cIQbCtw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-3.1.1.tgz", + "integrity": "sha512-Z1MhuMaRC1rv7hdK8p8I4u88seku358Je4q7OPVG+FRSZAfnUGXGRBYaHq34HYDjoz1axb+/irkk1WdJFs37Rg==", "license": "MIT", "dependencies": { - "@inertiajs/core": "2.2.15", - "@types/lodash-es": "^4.17.12", - "lodash-es": "^4.17.21" + "@inertiajs/core": "3.1.1", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" } }, "node_modules/@isaacs/cliui": { @@ -261,7 +271,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -271,14 +280,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1091,21 +1098,6 @@ "@types/node": "*" } }, - "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/node": { "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", @@ -1168,6 +1160,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1362,14 +1366,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/b64": { @@ -2435,6 +2440,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2944,9 +2959,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -2990,9 +3005,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3347,6 +3362,19 @@ "npm": ">=1.3.7" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/i18n-2": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/i18n-2/-/i18n-2-0.7.3.tgz", @@ -3407,9 +3435,9 @@ } }, "node_modules/inertia-sails": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/inertia-sails/-/inertia-sails-1.1.0.tgz", - "integrity": "sha512-HxeDiX1Bw5xVcd9wsLYnGrsjlCYP/HKyxDx9pF0GxICOjFN1Z+BKNX3cC3rwyJCSg2NbaQodZ8jLDZViFp/OQA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/inertia-sails/-/inertia-sails-1.4.0.tgz", + "integrity": "sha512-EBwelKZvUodGwcDSCVIg4cWTlb/rXff0vPswFsy3b5vIQTrvTvZsZukIu7v3sr8ke9APsR4si6AUESHaWFJsOA==", "license": "MIT", "peerDependencies": { "sails": ">=1", @@ -3704,6 +3732,23 @@ "graceful-fs": "^4.1.9" } }, + "node_modules/laravel-precognition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", + "integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==", + "license": "MIT", + "dependencies": { + "es-toolkit": "^1.32.0" + }, + "peerDependencies": { + "axios": "^1.4.0" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3751,12 +3796,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -4916,10 +4955,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.15.0", @@ -4987,21 +5029,6 @@ "node": ">=10.13.0" } }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -6292,6 +6319,13 @@ "@sailshq/lodash": "^3.10.2" } }, + "node_modules/sounding": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/sounding/-/sounding-0.0.1.tgz", + "integrity": "sha512-fwjwSgZYU8umo2IDD+8T45MurA7XLw0sledowT3MATfivMTryfbK+WLJAQj6e7mBXeumPrgnj357Zp87/ejmcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/templates/ascent-react/package.json b/templates/ascent-react/package.json index 0eb83275..cf111dac 100644 --- a/templates/ascent-react/package.json +++ b/templates/ascent-react/package.json @@ -12,14 +12,15 @@ "javascript" ], "dependencies": { - "@inertiajs/react": "^2.2.15", + "@inertiajs/react": "^3.1.1", "@sails-pay/lemonsqueezy": "^0.0.2", "@sailshq/connect-redis": "^6.1.3", "@sailshq/lodash": "^3.10.7", "@sailshq/socket.io-redis": "^6.1.2", "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", - "inertia-sails": "^1.1.0", + "axios": "^1.16.1", + "inertia-sails": "^1.4.0", "nodemailer": "^7.0.10", "primeicons": "^7.0.0", "primereact": "^10.9.7", @@ -53,8 +54,8 @@ "sails-hook-shipwright": "^1.1.0", "sails.io.js": "^1.2.1", "socket.io-client": "^4.8.1", - "tailwindcss": "^3.4.18", - "sounding": "^0.0.1" + "sounding": "^0.0.1", + "tailwindcss": "^3.4.18" }, "scripts": { "dev": "node --watch-path=api --watch-path=config app.js", diff --git a/templates/ascent-react/views/app.ejs b/templates/ascent-react/views/app.ejs index fc7131e4..50d53fa0 100644 --- a/templates/ascent-react/views/app.ejs +++ b/templates/ascent-react/views/app.ejs @@ -7,7 +7,10 @@ <%- shipwright.styles() %> -
+
+ <%- shipwright.scripts() %> diff --git a/templates/ascent-vue/api/controllers/security/disable-2fa.js b/templates/ascent-vue/api/controllers/security/disable-2fa.js index 766d9a02..263714ef 100644 --- a/templates/ascent-vue/api/controllers/security/disable-2fa.js +++ b/templates/ascent-vue/api/controllers/security/disable-2fa.js @@ -65,8 +65,8 @@ module.exports = { method === 'all' ? 'All two-factor authentication methods' : method === 'totp' - ? 'Authenticator app (TOTP)' - : 'Email verification (2FA)' + ? 'Authenticator app (TOTP)' + : 'Email verification (2FA)' this.req.flash('success', `${methodDisplayName} disabled successfully`) return '/settings/security' diff --git a/templates/ascent-vue/api/helpers/passkey/verify-authentication.js b/templates/ascent-vue/api/helpers/passkey/verify-authentication.js index 030ecf35..598eb7ef 100644 --- a/templates/ascent-vue/api/helpers/passkey/verify-authentication.js +++ b/templates/ascent-vue/api/helpers/passkey/verify-authentication.js @@ -100,9 +100,8 @@ module.exports = { requireUserVerification: true } - const verification = await verifyAuthenticationResponse( - verificationOptions - ) + const verification = + await verifyAuthenticationResponse(verificationOptions) if (!verification.verified) { return exits.verificationFailed() diff --git a/templates/ascent-vue/api/hooks/custom/index.js b/templates/ascent-vue/api/hooks/custom/index.js index ed36dc47..11ea95ab 100644 --- a/templates/ascent-vue/api/hooks/custom/index.js +++ b/templates/ascent-vue/api/hooks/custom/index.js @@ -54,9 +54,8 @@ module.exports = function defineCustomHook(sails) { } // Add avatar URL using helper - user.currentAvatarUrl = await sails.helpers.user.getAvatarUrl( - user - ) + user.currentAvatarUrl = + await sails.helpers.user.getAvatarUrl(user) return user }) diff --git a/templates/ascent-vue/api/models/Team.js b/templates/ascent-vue/api/models/Team.js index 9f76563d..69dfba26 100644 --- a/templates/ascent-vue/api/models/Team.js +++ b/templates/ascent-vue/api/models/Team.js @@ -68,9 +68,8 @@ module.exports = { beforeCreate: async function (valuesToSet, proceed) { // Generate invite token if not provided if (!valuesToSet.inviteToken) { - valuesToSet.inviteToken = await sails.helpers.strings.random( - 'url-friendly' - ) + valuesToSet.inviteToken = + await sails.helpers.strings.random('url-friendly') } return proceed() } diff --git a/templates/ascent-vue/assets/js/components/BackupCodesModal.vue b/templates/ascent-vue/assets/js/components/BackupCodesModal.vue index eee925e1..9c08c3c2 100644 --- a/templates/ascent-vue/assets/js/components/BackupCodesModal.vue +++ b/templates/ascent-vue/assets/js/components/BackupCodesModal.vue @@ -54,9 +54,9 @@ function handleSavedCodes() {
- +

{{ diff --git a/templates/ascent-vue/assets/js/components/Chips.vue b/templates/ascent-vue/assets/js/components/Chips.vue index 28c64c7c..ba95e0fa 100644 --- a/templates/ascent-vue/assets/js/components/Chips.vue +++ b/templates/ascent-vue/assets/js/components/Chips.vue @@ -91,7 +91,7 @@ function handleBlur() {