react-on-rails
Version:
react-on-rails JavaScript for react_on_rails Ruby gem
373 lines • 15.7 kB
JavaScript
import { authenticityToken } from "./Authenticity.js";
export class RailsActionRequestError extends Error {
constructor(response, responseBody, options = {}) {
super(`Rails action request failed with status ${response.status}`);
this.name = 'RailsActionRequestError';
this.response = response;
this.responseBody = responseBody;
if (options.cause !== undefined) {
Object.defineProperty(this, 'cause', {
configurable: true,
value: options.cause,
writable: true,
});
}
}
}
const resolveSameOriginRequestUrl = (url) => {
try {
// Ignore document.baseURI so a page-level <base> tag cannot rewrite Rails action paths before the origin check.
const resolvedUrl = new URL(url, window.location.href);
if ((resolvedUrl.protocol === 'http:' || resolvedUrl.protocol === 'https:') &&
resolvedUrl.origin === window.location.origin) {
return resolvedUrl.href;
}
}
catch {
return null;
}
return null;
};
const JSON_CONTENT_TYPE_PATTERN = /^(application\/json|[^/]+\/[^;]+\+json)(?:\s*;|\s*$)/i;
const isJsonResponse = (response) => JSON_CONTENT_TYPE_PATTERN.test(response.headers.get('Content-Type') ?? '');
const isAbortError = (error) => typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError';
const parseOptionalJsonBody = async (response) => {
if (!isJsonResponse(response)) {
return null;
}
try {
return (await response.json());
}
catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
return null;
}
};
const parseSuccessJsonBody = async (response) => {
if (response.status === 204 || response.status === 205) {
return null;
}
if (!isJsonResponse(response)) {
return null;
}
const responseText = await response.text();
return responseText.trim() === '' ? null : JSON.parse(responseText);
};
const warnOnPossibleRedirectFetchError = (fetchError) => {
if (process.env.NODE_ENV === 'production' || !(fetchError instanceof TypeError)) {
return;
}
if (!/failed to fetch|networkerror|load failed/i.test(fetchError.message)) {
return;
}
console.warn('[createRailsAction] The request may have failed because the server responded with a redirect or ' +
'because the network is unavailable. createRailsAction requires JSON responses; Rails `redirect_to` ' +
'is not supported for mutation endpoints.');
};
const warnOnDiscardedDeleteBody = (requestBody) => {
if (requestBody === undefined || requestBody === null) {
return false;
}
console.warn('[createRailsAction] A DELETE request resolved a JSON body that will not be sent. ' +
'Use `body: () => null` when variables only populate the path, or identify the resource in the URL instead.');
return true;
};
const warnOnImplicitBodyWithDynamicPath = () => {
console.warn('[createRailsAction] A dynamic path with no `body` mapper sends all variables as the JSON body. ' +
'Supply `body` to choose which fields are serialized, or `body: () => null` when variables only populate the path.');
};
const nonJsonBodyTypeName = (requestBody) => {
if (requestBody === undefined) {
return 'undefined';
}
if (typeof requestBody === 'number' && !Number.isFinite(requestBody)) {
return 'non-finite number';
}
if (typeof Date !== 'undefined' && requestBody instanceof Date && Number.isNaN(requestBody.getTime())) {
return 'invalid Date';
}
if (typeof Number !== 'undefined' &&
requestBody instanceof Number &&
!Number.isFinite(requestBody.valueOf())) {
return 'non-finite Number';
}
if (typeof FormData !== 'undefined' && requestBody instanceof FormData) {
return 'FormData';
}
if (typeof File !== 'undefined' && requestBody instanceof File) {
return 'File';
}
if (typeof Blob !== 'undefined' && requestBody instanceof Blob) {
return 'Blob';
}
if (typeof Headers !== 'undefined' && requestBody instanceof Headers) {
return 'Headers';
}
if (typeof Request !== 'undefined' && requestBody instanceof Request) {
return 'Request';
}
if (typeof Response !== 'undefined' && requestBody instanceof Response) {
return 'Response';
}
if (typeof URLSearchParams !== 'undefined' && requestBody instanceof URLSearchParams) {
return 'URLSearchParams';
}
if (typeof ArrayBuffer !== 'undefined' && requestBody instanceof ArrayBuffer) {
return 'ArrayBuffer';
}
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(requestBody)) {
return requestBody.constructor.name || 'ArrayBufferView';
}
if (typeof Map !== 'undefined' && requestBody instanceof Map) {
return 'Map';
}
if (typeof Set !== 'undefined' && requestBody instanceof Set) {
return 'Set';
}
if (typeof WeakMap !== 'undefined' && requestBody instanceof WeakMap) {
return 'WeakMap';
}
if (typeof WeakSet !== 'undefined' && requestBody instanceof WeakSet) {
return 'WeakSet';
}
if (typeof Error !== 'undefined' && requestBody instanceof Error) {
return requestBody.name || 'Error';
}
if (typeof RegExp !== 'undefined' && requestBody instanceof RegExp) {
return 'RegExp';
}
if (typeof requestBody === 'bigint') {
return 'BigInt';
}
if (typeof requestBody === 'function') {
return 'Function';
}
if (typeof requestBody === 'symbol') {
return 'Symbol';
}
if (typeof ReadableStream !== 'undefined' && requestBody instanceof ReadableStream) {
return 'ReadableStream';
}
if (typeof requestBody !== 'object' || requestBody === null) {
return null;
}
const maybeThenable = requestBody;
if (typeof maybeThenable.then === 'function') {
return 'Promise';
}
return null;
};
const preSerializationNonJsonBodyTypeName = (requestBody, activeObjects = new Set()) => {
const bodyTypeName = nonJsonBodyTypeName(requestBody);
if (bodyTypeName !== null) {
return bodyTypeName;
}
if (typeof requestBody !== 'object' || requestBody === null) {
return null;
}
const objectBody = requestBody;
if (typeof objectBody.toJSON === 'function') {
return null;
}
if (activeObjects.has(requestBody)) {
return 'circular object';
}
activeObjects.add(requestBody);
try {
const values = Array.isArray(requestBody)
? requestBody
: Object.values(requestBody);
for (const value of values) {
const nestedBodyTypeName = preSerializationNonJsonBodyTypeName(value, activeObjects);
if (nestedBodyTypeName !== null) {
return nestedBodyTypeName;
}
}
return null;
}
finally {
activeObjects.delete(requestBody);
}
};
const jsonBodyTypeError = (bodyTypeName) => new TypeError(`[createRailsAction] The request body resolved to ${bodyTypeName}, which cannot be JSON serialized correctly. ` +
'Return a plain JSON value, null, or undefined instead.');
const originalJsonBodyValue = (holder, key, rootBody) => {
if (key === '') {
return rootBody;
}
if (typeof holder !== 'object' || holder === null) {
return undefined;
}
return holder[key];
};
const stringifyJsonBody = (requestBody) => {
try {
const bodyTypeName = preSerializationNonJsonBodyTypeName(requestBody);
if (bodyTypeName !== null) {
throw jsonBodyTypeError(bodyTypeName);
}
const serializedBody = JSON.stringify(requestBody, function replaceJsonBodyValue(key, value) {
const originalValue = originalJsonBodyValue(this, key, requestBody);
const originalBodyTypeName = nonJsonBodyTypeName(originalValue);
if (originalBodyTypeName !== null) {
throw jsonBodyTypeError(originalBodyTypeName);
}
const replacedBodyTypeName = nonJsonBodyTypeName(value);
if (replacedBodyTypeName !== null) {
throw jsonBodyTypeError(replacedBodyTypeName);
}
return value;
});
if (serializedBody === undefined) {
throw jsonBodyTypeError('undefined');
}
return serializedBody;
}
catch (error) {
if (error instanceof TypeError && /BigInt/i.test(error.message)) {
throw new TypeError('[createRailsAction] The request body contains a BigInt value, which cannot be JSON serialized correctly. ' +
'Convert BigInt values to strings or numbers before returning the body.');
}
throw error;
}
};
const mergeHeaders = (...headersList) => {
const headers = new Headers();
headersList.forEach((headersInit) => {
if (headersInit === undefined) {
return;
}
new Headers(headersInit).forEach((value, key) => {
headers.set(key, value);
});
});
return headers;
};
const buildRailsActionHeaders = (csrfToken, hasJsonBody, ...headersList) => {
const headers = mergeHeaders({ Accept: 'application/json' }, ...headersList);
if (hasJsonBody) {
headers.set('Content-Type', 'application/json');
}
else {
headers.delete('Content-Type');
}
headers.set('X-CSRF-Token', csrfToken);
headers.set('X-Requested-With', 'XMLHttpRequest');
return headers;
};
const resolvePath = (path, variables) => typeof path === 'function' ? path(variables) : path;
const resolveHeaders = (headers, variables) => (typeof headers === 'function' ? headers(variables) : headers);
const hasOwnProperty = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
function callOptionsValue(callOptions, key) {
if (typeof callOptions !== 'object' || callOptions === null) {
return undefined;
}
return hasOwnProperty(callOptions, key) ? callOptions[key] : undefined;
}
const assertBrowserContext = () => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('createRailsAction can only be used in browser contexts.');
}
};
/**
* Creates a CSRF-aware JSON caller for a Rails controller action.
*
* Supply the response generic from the generated Rails response declarations:
*
* ```ts
* type CreateProjectResponse = RailsResponseType<'projects.create'>;
* const createProject = createRailsAction<CreateProjectVariables, CreateProjectResponse>({
* path: '/api/projects',
* });
* ```
*
* The returned function is directly usable as a TanStack Query `mutationFn`.
* It always requests JSON, rejects browser-followed redirects, and resolves 204 or non-JSON success
* responses as `null`. Include `null` in `TResponse` when a successful empty response is expected.
* A 200 response with `text/html`, such as an unexpected Rails error page, also resolves as `null`.
* `options.headers` can override the default `Accept: application/json`; include `null` in `TResponse`
* when that custom Accept header may produce a successful non-JSON response.
* Omitting `body` sends `variables` as the JSON body verbatim; supply `body` to map or filter fields before
* serialization.
* Body values that would serialize lossy or outside JSON, including nested `undefined`, `BigInt`, and
* non-finite numbers, are rejected before `fetch` runs.
* When `variables` only populate `path`, supply `body: () => null` or a mapper to avoid forwarding them.
* Return `null` or `undefined` from `body` when the request should not send JSON. DELETE requests never
* send a JSON body; identify the resource in the URL instead.
*/
export function createRailsAction(options) {
const method = (options.method ?? 'POST').toUpperCase();
// DELETE body discard is an action configuration problem, so warn at most once per DELETE action factory.
let warnedOnDiscardedDeleteBody = method !== 'DELETE' || process.env.NODE_ENV === 'production';
let warnedOnImplicitDynamicPathBody = method === 'DELETE' ||
typeof options.path !== 'function' ||
options.body !== undefined ||
process.env.NODE_ENV === 'production';
const callRailsAction = async (variables, callOptions = {}) => {
// The public conditional type only permits omitted variables when TVariables is undefined.
const typedVariables = variables;
assertBrowserContext();
const resolvedPath = resolvePath(options.path, typedVariables);
const requestUrl = resolveSameOriginRequestUrl(resolvedPath);
if (requestUrl === null) {
throw new Error('createRailsAction can only call same-origin Rails action URLs. ' +
'Ensure the path resolves to the same origin as the current page.');
}
const csrfToken = authenticityToken()?.trim();
if (!csrfToken) {
throw new Error('createRailsAction requires a <meta name="csrf-token"> tag before submitting. ' +
'Add <%= csrf_meta_tags %> to your Rails layout.');
}
const requestBody = options.body !== undefined ? options.body(typedVariables) : typedVariables;
const hasJsonBody = method !== 'DELETE' && requestBody !== undefined && requestBody !== null;
const shouldWarnOnDiscardedDeleteBody = !warnedOnDiscardedDeleteBody && requestBody !== undefined && requestBody !== null;
const shouldWarnOnImplicitDynamicPathBody = !warnedOnImplicitDynamicPathBody && hasJsonBody;
const serializedRequestBody = hasJsonBody ? stringifyJsonBody(requestBody) : undefined;
if (shouldWarnOnDiscardedDeleteBody) {
warnOnDiscardedDeleteBody(requestBody);
warnedOnDiscardedDeleteBody = true;
}
if (shouldWarnOnImplicitDynamicPathBody) {
warnOnImplicitBodyWithDynamicPath();
warnedOnImplicitDynamicPathBody = true;
}
let response;
try {
response = await fetch(requestUrl, {
method,
mode: 'same-origin',
credentials: 'same-origin',
redirect: 'error',
signal: callOptionsValue(callOptions, 'signal'),
headers: buildRailsActionHeaders(csrfToken, hasJsonBody, resolveHeaders(options.headers, typedVariables), callOptionsValue(callOptions, 'headers')),
body: serializedRequestBody,
});
}
catch (fetchError) {
warnOnPossibleRedirectFetchError(fetchError);
throw fetchError;
}
if (!response.ok) {
// Keep this clone before any error-body reads so callers can still inspect the original response body.
let responseBody = null;
let bodyReadError;
try {
responseBody = await parseOptionalJsonBody(response.clone());
}
catch (error) {
if (isAbortError(error)) {
throw error;
}
bodyReadError = error;
responseBody = null;
}
throw new RailsActionRequestError(response, responseBody, { cause: bodyReadError });
}
const responseBody = await parseSuccessJsonBody(response);
return responseBody;
};
return callRailsAction;
}
//# sourceMappingURL=railsAction.js.map