1- import type { AxiosInstance } from "axios" ;
1+ import type { AxiosInstance , AxiosHeaders } from "axios" ;
22
33declare global {
44 interface Window {
@@ -33,6 +33,22 @@ const getSessionInfo = async function (): Promise<SessionInfo> {
3333// Don't store the promise, store the session info once it's resolved
3434let sessionInfo : SessionInfo | null = null ;
3535
36+ /**
37+ * Craft CMS submission requirements:
38+ *
39+ * - If specifying application/json as content-type header:
40+ * - Using Inertia's Form component, include the "action" parameter
41+ * - <Form method="post" action="/actions/...">
42+ * - Or POST directly to a /actions/ endpoint.
43+ * - (useForm) form.post("/actions/...")
44+ *
45+ * - Default: If using application/x-www-form-urlencoded content-type header:
46+ * - Include "action" parameter in the form data (no /actions/ prefix)
47+ * - <input type="hidden" name="action" value="entries/save-entry">
48+ * - Or POST to the current URL ("")
49+ * - (useForm) form.post("")
50+ */
51+
3652const getActionPath = ( url : string ) => {
3753 const postPathObject : URL = new URL ( url ) ;
3854 const postPathPathname : string = postPathObject . pathname ;
@@ -63,6 +79,48 @@ const getTokenFromMeta = (): csrfMeta | null => {
6379 } ;
6480} ;
6581
82+ /**
83+ * Replaces empty arrays in an object with an empty string, up to a max depth.
84+ * @param obj The object to process
85+ * @param maxDepth Maximum depth to traverse (default: 10)
86+ * @param currentDepth Current depth (for internal use)
87+ */
88+ const replaceEmptyArrays = ( obj : any , maxDepth = 10 , currentDepth = 0 ) : any => {
89+ if ( currentDepth > maxDepth ) {
90+ return obj ;
91+ }
92+ if ( Array . isArray ( obj ) ) {
93+ return obj . map ( ( item ) =>
94+ replaceEmptyArrays ( item , maxDepth , currentDepth + 1 )
95+ ) ;
96+ } else if ( typeof obj === "object" && obj !== null ) {
97+ return Object . fromEntries (
98+ Object . entries ( obj ) . map ( ( [ key , value ] ) => [
99+ key ,
100+ Array . isArray ( value ) && value . length === 0
101+ ? ""
102+ : replaceEmptyArrays ( value , maxDepth , currentDepth + 1 ) ,
103+ ] )
104+ ) ;
105+ }
106+ return obj ;
107+ } ;
108+
109+ const getContentType = (
110+ headers : AxiosHeaders | Record < string , any >
111+ ) : string | undefined => {
112+ // AxiosHeaders may have a .get() method, otherwise treat as plain object
113+ if ( typeof ( headers as any ) . get === "function" ) {
114+ return ( headers as any ) . get ( "content-type" ) ;
115+ }
116+ for ( const key in headers ) {
117+ if ( key . toLowerCase ( ) === "content-type" ) {
118+ return headers [ key ] ;
119+ }
120+ }
121+ return undefined ;
122+ } ;
123+
66124const setCsrfOnMeta = ( csrfTokenName : string , csrfTokenValue : string ) : void => {
67125 // Check if a CSRF meta element already exists
68126 let csrfMetaEl = document . head . querySelector ( "meta[csrf]" ) ;
@@ -82,83 +140,90 @@ const setCsrfOnMeta = (csrfTokenName: string, csrfTokenValue: string): void => {
82140} ;
83141
84142const configureAxios = async ( ) => {
85- window . axios . defaults . headers = {
86- "Content-Type" : "multipart/form-data" ,
87- } ;
88-
89143 ( window . axios as AxiosInstance ) . interceptors . request . use ( async ( config ) => {
90- if ( config . method === "post" || config . method === "put" ) {
91- let csrfMeta = getTokenFromMeta ( ) ;
92- if ( ! csrfMeta ) {
93- // Wait for the session info to be resolved before configuring axios
94- sessionInfo = await getSessionInfo ( ) ;
95- if ( ! sessionInfo . isGuest ) {
96- setCsrfOnMeta ( sessionInfo . csrfTokenName , sessionInfo . csrfTokenValue ) ;
97- csrfMeta = getTokenFromMeta ( ) ;
98- }
144+ if ( config . method !== "post" && config . method !== "put" ) {
145+ return config ;
146+ }
147+
148+ let csrfMeta = getTokenFromMeta ( ) ;
149+ if ( ! csrfMeta ) {
150+ // Wait for the session info to be resolved before configuring axios
151+ sessionInfo = await getSessionInfo ( ) ;
152+ if ( ! sessionInfo . isGuest ) {
153+ setCsrfOnMeta ( sessionInfo . csrfTokenName , sessionInfo . csrfTokenValue ) ;
154+ csrfMeta = getTokenFromMeta ( ) ;
99155 }
156+ }
100157
101- const csrf = csrfMeta || sessionInfo ;
158+ const csrf = csrfMeta || sessionInfo ;
102159
103- if ( ! csrf ) {
104- throw new Error ( "CSRF token not found" ) ;
105- }
160+ if ( ! csrf ) {
161+ throw new Error (
162+ "Inertia (Craft): CSRF token not found. Ensure session is initialized or meta tag is present."
163+ ) ;
164+ }
106165
107- const actionPath = getActionPath ( config . url ) ;
166+ const actionPath = getActionPath ( config . url ?? "" ) ;
108167
109- config . url = "" ;
168+ if ( getContentType ( config . headers ) == undefined ) {
169+ config . headers . set ( "Content-Type" , "application/x-www-form-urlencoded" ) ;
170+ }
110171
111- if ( config . data instanceof FormData ) {
112- config . data . append ( csrf . csrfTokenName , csrf . csrfTokenValue ) ;
172+ if ( config . data instanceof FormData ) {
173+ if ( ! config . data . has ( "action" ) ) {
113174 config . data . append ( "action" , actionPath ) ;
114- // NOTE: FormData cannot represent empty arrays. If you need to send empty arrays,
115- // add a placeholder value (e.g., an empty string or special marker) when building the FormData.
116- // Example:
117- // if (myArray.length === 0) formData.append('myArray', '');
118- } else {
119- // For plain objects, replace empty arrays with empty strings before sending
120- const replaceEmptyArrays = ( obj : any ) : any => {
121- if ( Array . isArray ( obj ) ) {
122- return obj . map ( ( item ) => replaceEmptyArrays ( item ) ) ;
123- } else if ( typeof obj === "object" && obj !== null ) {
124- return Object . fromEntries (
125- Object . entries ( obj ) . map ( ( [ key , value ] ) => [
126- key ,
127- Array . isArray ( value ) && value . length === 0
128- ? ""
129- : replaceEmptyArrays ( value ) ,
130- ] )
131- ) ;
132- }
133- return obj ;
134- } ;
135-
136- const contentType =
137- config . headers [ "Content-Type" ] ||
138- config . headers [ "content-type" ] ||
139- config . headers [ "CONTENT-TYPE" ] ;
140-
141- let data = {
142- [ csrf . csrfTokenName ] : csrf . csrfTokenValue ,
143- action : actionPath ,
144- ...config . data ,
145- } ;
146- if (
147- typeof contentType === "string" &&
148- contentType . toLowerCase ( ) . includes ( "multipart/form-data" )
149- ) {
150- data = replaceEmptyArrays ( data ) ;
151- }
152- config . data = data ;
175+ config . url = "" ;
176+ }
177+ config . data . append ( csrf . csrfTokenName , csrf . csrfTokenValue ) ;
178+
179+ /** NOTE: FormData cannot represent empty arrays. If you need to send empty arrays as values,
180+ * add a placeholder value (e.g., an empty string or special marker) when building the FormData.
181+ * eg, if (myArray.length === 0) formData.append('myArray', '');
182+ */
183+ } else {
184+ let data = {
185+ [ csrf . csrfTokenName ] : csrf . csrfTokenValue ,
186+ action : actionPath ,
187+ ...config . data ,
188+ } ;
189+
190+ const contentType = getContentType ( config . headers ?? { } ) ;
191+ if (
192+ typeof contentType === "string" &&
193+ contentType . toLowerCase ( ) . includes ( "multipart/form-data" )
194+ ) {
195+ data = replaceEmptyArrays ( data ) ;
153196 }
197+ config . data = data ;
154198 }
199+
155200 return config ;
156201 } ) ;
157202
158203 // Add a response interceptor
159204 ( window . axios as AxiosInstance ) . interceptors . response . use (
160205 async ( response ) => {
161- if ( response . config . data ?. get ( "action" ) == "users/login" ) {
206+ // Support both FormData and plain object/stringified data
207+ let action = null ;
208+ if ( response . config . data instanceof FormData ) {
209+ action = response . config . data . get ( "action" ) ;
210+ } else if (
211+ typeof response . config . data === "object" &&
212+ response . config . data !== null
213+ ) {
214+ action = response . config . data . action ;
215+ } else if ( typeof response . config . data === "string" ) {
216+ // Try to parse as JSON or URL-encoded
217+ try {
218+ const parsed = JSON . parse ( response . config . data ) ;
219+ action = parsed . action ;
220+ } catch {
221+ // Try URLSearchParams
222+ const params = new URLSearchParams ( response . config . data ) ;
223+ action = params . get ( "action" ) ;
224+ }
225+ }
226+ if ( action === "users/login" ) {
162227 await getSessionInfo ( ) . then ( ( sessionInfo ) => {
163228 setCsrfOnMeta ( sessionInfo . csrfTokenName , sessionInfo . csrfTokenValue ) ;
164229 } ) ;
0 commit comments