@hyperspan/framework 1.0.24 → 1.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +1 -3
- package/package.json +1 -1
- package/src/actions.test.ts +268 -50
- package/src/actions.ts +62 -34
- package/src/client/_hs/hyperspan-actions.client.ts +50 -22
- package/src/index.ts +12 -2
- package/src/server.test.ts +191 -5
- package/src/server.ts +138 -47
- package/src/ssr/mock-dom.test.ts +1 -1
- package/src/ssr/mock-dom.ts +12 -9
- package/src/types.ts +130 -42
package/package.json
CHANGED
package/src/actions.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test, expect, describe } from 'bun:test';
|
|
2
2
|
import { createAction } from './actions';
|
|
3
|
-
import {
|
|
3
|
+
import { createRoute } from './server';
|
|
4
|
+
import { html, render, placeholder, type HSHtml } from '@hyperspan/html';
|
|
4
5
|
import { createContext } from './server';
|
|
5
6
|
import * as z from 'zod';
|
|
6
7
|
|
|
@@ -11,18 +12,20 @@ describe('createAction', () => {
|
|
|
11
12
|
schema: z.object({
|
|
12
13
|
name: z.string().min(1, 'Name is required'),
|
|
13
14
|
}),
|
|
14
|
-
})
|
|
15
|
-
|
|
15
|
+
})
|
|
16
|
+
.form((c) => {
|
|
17
|
+
return html`
|
|
16
18
|
<form>
|
|
17
19
|
<input type="text" name="name" />
|
|
18
20
|
<button type="submit">Submit</button>
|
|
19
21
|
</form>
|
|
20
22
|
`;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
})
|
|
24
|
+
.post(async (c, { data }) => {
|
|
25
|
+
return c.res.html(`
|
|
23
26
|
<p>Hello, ${data?.name}!</p>
|
|
24
27
|
`);
|
|
25
|
-
|
|
28
|
+
});
|
|
26
29
|
|
|
27
30
|
expect(action).toBeDefined();
|
|
28
31
|
expect(action._kind).toBe('hsAction');
|
|
@@ -42,18 +45,20 @@ describe('createAction', () => {
|
|
|
42
45
|
test('creates an action with a simple form and no schema that returns HTML on POST', async () => {
|
|
43
46
|
const action = createAction({
|
|
44
47
|
name: 'test',
|
|
45
|
-
})
|
|
46
|
-
|
|
48
|
+
})
|
|
49
|
+
.form((c) => {
|
|
50
|
+
return html`
|
|
47
51
|
<form>
|
|
48
52
|
<input type="text" name="name" />
|
|
49
53
|
<button type="submit">Submit</button>
|
|
50
54
|
</form>
|
|
51
55
|
`;
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
})
|
|
57
|
+
.post(async (c, { data }) => {
|
|
58
|
+
return c.res.html(`
|
|
54
59
|
<p>Hello, ${data?.name}!</p>
|
|
55
60
|
`);
|
|
56
|
-
|
|
61
|
+
});
|
|
57
62
|
|
|
58
63
|
// Build form data
|
|
59
64
|
const formData = new FormData();
|
|
@@ -74,16 +79,18 @@ describe('createAction', () => {
|
|
|
74
79
|
test('returns HSHtml directly from POST handler', async () => {
|
|
75
80
|
const action = createAction({
|
|
76
81
|
name: 'test',
|
|
77
|
-
})
|
|
78
|
-
|
|
82
|
+
})
|
|
83
|
+
.form((c) => {
|
|
84
|
+
return html`
|
|
79
85
|
<form>
|
|
80
86
|
<input type="text" name="name" />
|
|
81
87
|
<button type="submit">Submit</button>
|
|
82
88
|
</form>
|
|
83
89
|
`;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
})
|
|
91
|
+
.post(async (c, { data }) => {
|
|
92
|
+
return html`<p>Hello, ${data?.name}!</p>`;
|
|
93
|
+
});
|
|
87
94
|
|
|
88
95
|
const formData = new FormData();
|
|
89
96
|
formData.append('name', 'Jane Doe');
|
|
@@ -102,22 +109,22 @@ describe('createAction', () => {
|
|
|
102
109
|
test('errors thrown on POST handler provided by user are caught and rendered', async () => {
|
|
103
110
|
const action = createAction({
|
|
104
111
|
name: 'test',
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<p>Error: ${error.message}</p>
|
|
109
|
-
|
|
110
|
-
}
|
|
112
|
+
})
|
|
113
|
+
.form((c, { error }) => {
|
|
114
|
+
if (error) {
|
|
115
|
+
return html` <p>Error: ${error.message}</p> `;
|
|
116
|
+
}
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
return html`
|
|
113
119
|
<form>
|
|
114
120
|
<input type="text" name="name" />
|
|
115
121
|
<button type="submit">Submit</button>
|
|
116
122
|
</form>
|
|
117
123
|
`;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
})
|
|
125
|
+
.post(async (c, { data }) => {
|
|
126
|
+
throw new Error('Test error');
|
|
127
|
+
});
|
|
121
128
|
|
|
122
129
|
// Build form data
|
|
123
130
|
const formData = new FormData();
|
|
@@ -144,20 +151,22 @@ describe('createAction', () => {
|
|
|
144
151
|
const action = createAction({
|
|
145
152
|
name: 'test',
|
|
146
153
|
schema,
|
|
147
|
-
})
|
|
148
|
-
|
|
154
|
+
})
|
|
155
|
+
.form((c, { data }) => {
|
|
156
|
+
return html`
|
|
149
157
|
<form>
|
|
150
158
|
<input type="text" name="name" />
|
|
151
159
|
<input type="email" name="email" />
|
|
152
160
|
<button type="submit">Submit</button>
|
|
153
161
|
</form>
|
|
154
162
|
`;
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
})
|
|
164
|
+
.post(async (c, { data }) => {
|
|
165
|
+
return c.res.html(`
|
|
157
166
|
<p>Hello, ${data?.name}!</p>
|
|
158
167
|
<p>Your email is ${data?.email}.</p>
|
|
159
168
|
`);
|
|
160
|
-
|
|
169
|
+
});
|
|
161
170
|
|
|
162
171
|
expect(action).toBeDefined();
|
|
163
172
|
expect(action._kind).toBe('hsAction');
|
|
@@ -201,8 +210,9 @@ describe('createAction', () => {
|
|
|
201
210
|
const action = createAction({
|
|
202
211
|
name: 'test',
|
|
203
212
|
schema,
|
|
204
|
-
})
|
|
205
|
-
|
|
213
|
+
})
|
|
214
|
+
.form((c, { data, error }) => {
|
|
215
|
+
return html`
|
|
206
216
|
<form>
|
|
207
217
|
<input type="text" name="name" value="${data?.name || ''}" />
|
|
208
218
|
${error ? html`<div class="error">Validation failed</div>` : ''}
|
|
@@ -210,12 +220,13 @@ describe('createAction', () => {
|
|
|
210
220
|
<button type="submit">Submit</button>
|
|
211
221
|
</form>
|
|
212
222
|
`;
|
|
213
|
-
|
|
214
|
-
|
|
223
|
+
})
|
|
224
|
+
.post(async (c, { data }) => {
|
|
225
|
+
return c.res.html(`
|
|
215
226
|
<p>Hello, ${data?.name}!</p>
|
|
216
227
|
<p>Your email is ${data?.email}.</p>
|
|
217
228
|
`);
|
|
218
|
-
|
|
229
|
+
});
|
|
219
230
|
|
|
220
231
|
// Test fetch method with invalid data (missing name, invalid email)
|
|
221
232
|
const formData = new FormData();
|
|
@@ -248,8 +259,9 @@ describe('createAction', () => {
|
|
|
248
259
|
const action = createAction({
|
|
249
260
|
name: 'test',
|
|
250
261
|
schema,
|
|
251
|
-
})
|
|
252
|
-
|
|
262
|
+
})
|
|
263
|
+
.form((c, { data, error }) => {
|
|
264
|
+
return html`
|
|
253
265
|
<form>
|
|
254
266
|
<input type="text" name="name" value="${data?.name || ''}" />
|
|
255
267
|
${error ? html`<div class="error">Validation failed</div>` : ''}
|
|
@@ -257,17 +269,19 @@ describe('createAction', () => {
|
|
|
257
269
|
<button type="submit">Submit</button>
|
|
258
270
|
</form>
|
|
259
271
|
`;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
})
|
|
273
|
+
.post(async (c, { data }) => {
|
|
274
|
+
return html`
|
|
275
|
+
<p>Hello, ${data?.name}!</p>
|
|
276
|
+
<p>Your email is ${data?.email}.</p>
|
|
277
|
+
`;
|
|
278
|
+
})
|
|
279
|
+
.errorHandler(async (c, { data, error }) => {
|
|
280
|
+
return html`
|
|
281
|
+
<p>Caught error in custom error handler: ${error?.message}</p>
|
|
282
|
+
<p>Data: ${JSON.stringify(data)}</p>
|
|
283
|
+
`;
|
|
284
|
+
});
|
|
271
285
|
|
|
272
286
|
// Test fetch method with invalid data (missing name, invalid email)
|
|
273
287
|
const formData = new FormData();
|
|
@@ -288,5 +302,209 @@ describe('createAction', () => {
|
|
|
288
302
|
// Should NOT contain the success message from post handler
|
|
289
303
|
expect(responseText).not.toContain('Hello,');
|
|
290
304
|
});
|
|
291
|
-
});
|
|
292
305
|
|
|
306
|
+
describe('when streaming is disabled', () => {
|
|
307
|
+
test('action POST returns resolved placeholder content on the route', async () => {
|
|
308
|
+
const action = createAction({ name: 'async-placeholder-action-route' })
|
|
309
|
+
.form(() => html`<form><button type="submit">Submit</button></form>`)
|
|
310
|
+
.post(async () => {
|
|
311
|
+
return html`<div>
|
|
312
|
+
${placeholder(
|
|
313
|
+
html`<span>Saving...</span>`,
|
|
314
|
+
(async () => {
|
|
315
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
316
|
+
return html`<p>Action complete</p>`;
|
|
317
|
+
})()
|
|
318
|
+
)}
|
|
319
|
+
</div>`;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
action._config.responseOptions = {
|
|
323
|
+
disableStreaming: () => true,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const request = new Request(`http://localhost:3000${action._path()}`, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers: {
|
|
329
|
+
Accept: 'text/html',
|
|
330
|
+
'X-Request-Type': 'partial',
|
|
331
|
+
},
|
|
332
|
+
body: new FormData(),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const response = await action.fetch(request);
|
|
336
|
+
|
|
337
|
+
expect(response).toBeInstanceOf(Response);
|
|
338
|
+
expect(response.status).toBe(200);
|
|
339
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
340
|
+
|
|
341
|
+
const text = await response.text();
|
|
342
|
+
expect(text).toContain('<p>Action complete</p>');
|
|
343
|
+
expect(text).not.toContain('Saving...');
|
|
344
|
+
expect(text).not.toContain('hs:loading');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('action POST returns resolved placeholder content on the server', async () => {
|
|
348
|
+
const action = createAction({ name: 'async-placeholder-action-server' })
|
|
349
|
+
.form(() => html`<form><button type="submit">Submit</button></form>`)
|
|
350
|
+
.post(async () => {
|
|
351
|
+
return html`<div>
|
|
352
|
+
${placeholder(
|
|
353
|
+
html`<span>Saving...</span>`,
|
|
354
|
+
(async () => {
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
356
|
+
return html`<p>Action complete</p>`;
|
|
357
|
+
})()
|
|
358
|
+
)}
|
|
359
|
+
</div>`;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
action._serverConfig = {
|
|
363
|
+
appDir: './app',
|
|
364
|
+
publicDir: './public',
|
|
365
|
+
plugins: [],
|
|
366
|
+
responseOptions: {
|
|
367
|
+
disableStreaming: () => true,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const request = new Request(`http://localhost:3000${action._path()}`, {
|
|
372
|
+
method: 'POST',
|
|
373
|
+
headers: {
|
|
374
|
+
Accept: 'text/html',
|
|
375
|
+
'X-Request-Type': 'partial',
|
|
376
|
+
},
|
|
377
|
+
body: new FormData(),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const response = await action.fetch(request);
|
|
381
|
+
|
|
382
|
+
expect(response).toBeInstanceOf(Response);
|
|
383
|
+
expect(response.status).toBe(200);
|
|
384
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
385
|
+
|
|
386
|
+
const text = await response.text();
|
|
387
|
+
expect(text).toContain('<p>Action complete</p>');
|
|
388
|
+
expect(text).not.toContain('Saving...');
|
|
389
|
+
expect(text).not.toContain('hs:loading');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('action GET returns resolved placeholder content on the server', async () => {
|
|
393
|
+
const action = createAction({ name: 'async-placeholder-action-get' })
|
|
394
|
+
.form(() => {
|
|
395
|
+
return html`<div>
|
|
396
|
+
${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Form ready</p>`))}
|
|
397
|
+
</div>`;
|
|
398
|
+
})
|
|
399
|
+
.post(async () => html`<p>Action complete</p>`);
|
|
400
|
+
|
|
401
|
+
action._serverConfig = {
|
|
402
|
+
appDir: './app',
|
|
403
|
+
publicDir: './public',
|
|
404
|
+
plugins: [],
|
|
405
|
+
responseOptions: {
|
|
406
|
+
disableStreaming: () => true,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const response = await action.fetch(new Request(`http://localhost:3000${action._path()}`));
|
|
411
|
+
|
|
412
|
+
expect(response.status).toBe(200);
|
|
413
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
414
|
+
|
|
415
|
+
const text = await response.text();
|
|
416
|
+
expect(text).toContain('<p>Form ready</p>');
|
|
417
|
+
expect(text).not.toContain('Loading...');
|
|
418
|
+
expect(text).not.toContain('hs:loading');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('embedded action.render() uses the parent page route streaming config, not the action server config', async () => {
|
|
422
|
+
const action = createAction({ name: 'embedded-action-streaming' })
|
|
423
|
+
.form(() => {
|
|
424
|
+
return html`<div>
|
|
425
|
+
${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Form ready</p>`))}
|
|
426
|
+
</div>`;
|
|
427
|
+
})
|
|
428
|
+
.post(async () => html`<p>Action complete</p>`);
|
|
429
|
+
|
|
430
|
+
action._serverConfig = {
|
|
431
|
+
appDir: './app',
|
|
432
|
+
publicDir: './public',
|
|
433
|
+
plugins: [],
|
|
434
|
+
responseOptions: {
|
|
435
|
+
disableStreaming: () => true,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const pageRoute = createRoute().get((c) => html`<main>${action.render(c)}</main>`);
|
|
440
|
+
let pageResponse = await pageRoute.fetch(new Request('http://localhost:3000/'));
|
|
441
|
+
expect(pageResponse.headers.get('Transfer-Encoding')).toBe('chunked');
|
|
442
|
+
expect(await pageResponse.text()).toContain('Loading...');
|
|
443
|
+
|
|
444
|
+
pageRoute._serverConfig = {
|
|
445
|
+
appDir: './app',
|
|
446
|
+
publicDir: './public',
|
|
447
|
+
plugins: [],
|
|
448
|
+
responseOptions: {
|
|
449
|
+
disableStreaming: () => true,
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
pageResponse = await pageRoute.fetch(new Request('http://localhost:3000/'));
|
|
454
|
+
expect(pageResponse.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
455
|
+
|
|
456
|
+
const text = await pageResponse.text();
|
|
457
|
+
expect(text).toContain('<p>Form ready</p>');
|
|
458
|
+
expect(text).not.toContain('Loading...');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('action validation error re-render returns resolved placeholder content', async () => {
|
|
462
|
+
const schema = z.object({ name: z.string().min(1, 'Name is required') });
|
|
463
|
+
|
|
464
|
+
const action = createAction({ name: 'async-placeholder-action-error', schema })
|
|
465
|
+
.form(
|
|
466
|
+
(c, { error }) => html`
|
|
467
|
+
<form>
|
|
468
|
+
<div>
|
|
469
|
+
${placeholder(
|
|
470
|
+
html`<span>Loading...</span>`,
|
|
471
|
+
Promise.resolve(html`<p>Form ready</p>`)
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
${error ? html`<p class="error">${error.message}</p>` : ''}
|
|
475
|
+
<button type="submit">Submit</button>
|
|
476
|
+
</form>
|
|
477
|
+
`
|
|
478
|
+
)
|
|
479
|
+
.post(async () => html`<p>Action complete</p>`);
|
|
480
|
+
|
|
481
|
+
action._serverConfig = {
|
|
482
|
+
appDir: './app',
|
|
483
|
+
publicDir: './public',
|
|
484
|
+
plugins: [],
|
|
485
|
+
responseOptions: {
|
|
486
|
+
disableStreaming: () => true,
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const response = await action.fetch(
|
|
491
|
+
new Request(`http://localhost:3000${action._path()}`, {
|
|
492
|
+
method: 'POST',
|
|
493
|
+
headers: {
|
|
494
|
+
Accept: 'text/html',
|
|
495
|
+
'X-Request-Type': 'partial',
|
|
496
|
+
},
|
|
497
|
+
body: new FormData(),
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
expect(response.status).toBe(400);
|
|
502
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
503
|
+
|
|
504
|
+
const text = await response.text();
|
|
505
|
+
expect(text).toContain('<p>Form ready</p>');
|
|
506
|
+
expect(text).not.toContain('Loading...');
|
|
507
|
+
expect(text).toContain('class="error"');
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
});
|
package/src/actions.ts
CHANGED
|
@@ -8,7 +8,9 @@ import { validateBody, ZodValidationError } from './middleware';
|
|
|
8
8
|
import { debug } from 'debug';
|
|
9
9
|
|
|
10
10
|
const log = debug('hyperspan:actions');
|
|
11
|
-
const actionsClientJS = await buildClientJS(
|
|
11
|
+
const actionsClientJS = await buildClientJS(
|
|
12
|
+
import.meta.resolve('./client/_hs/hyperspan-actions.client')
|
|
13
|
+
);
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Actions = Form + route handler
|
|
@@ -23,59 +25,76 @@ const actionsClientJS = await buildClientJS(import.meta.resolve('./client/_hs/hy
|
|
|
23
25
|
* 5. Replaces form content in place with HTML response content from server via the Idiomorph library
|
|
24
26
|
* 6. Handles any Exception thrown on server as error displayed back to user on the page
|
|
25
27
|
*/
|
|
26
|
-
export function createAction<S extends z.ZodType>(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
): HS.Action<S | undefined> {
|
|
28
|
+
export function createAction<S extends z.ZodType>(params: {
|
|
29
|
+
name: string;
|
|
30
|
+
schema: S;
|
|
31
|
+
}): HS.Action<S>;
|
|
32
|
+
export function createAction(params: { name: string; schema?: undefined }): HS.Action<undefined>;
|
|
33
|
+
export function createAction<S extends z.ZodType>(params: {
|
|
34
|
+
name: string;
|
|
35
|
+
schema?: S;
|
|
36
|
+
}): HS.Action<S | undefined> {
|
|
35
37
|
const { name, schema } = params;
|
|
36
38
|
const path = `/__actions/${assetHash(name)}`;
|
|
37
39
|
|
|
38
40
|
let _handler: Parameters<HS.Action<S | undefined>['post']>[0] | null = null;
|
|
39
41
|
let _errorHandler: Parameters<HS.Action<S | undefined>['errorHandler']>[0] | null = null;
|
|
40
42
|
|
|
43
|
+
function actionResponseOptions(status?: number) {
|
|
44
|
+
return {
|
|
45
|
+
...(route._serverConfig?.responseOptions ?? {}),
|
|
46
|
+
...(route._config?.responseOptions ?? {}),
|
|
47
|
+
...(status !== undefined ? { status } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
const route = createRoute({ path, name })
|
|
42
52
|
.get((c: HS.Context) => api.render(c))
|
|
43
|
-
.post(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
.post(
|
|
54
|
+
async (c: HS.Context) => {
|
|
55
|
+
if (!_handler) {
|
|
56
|
+
throw new Error('Action POST handler not set! Every action must have a POST handler.');
|
|
57
|
+
}
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const data =
|
|
60
|
+
(c.vars.body as HS.InferActionData<S>) || formDataToJSON(await c.req.formData()) || {};
|
|
61
|
+
log('POST handler', { data });
|
|
62
|
+
const response = await _handler(c, { data });
|
|
63
|
+
log('POST handler response', { response });
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
if (response instanceof Response) {
|
|
66
|
+
// Replace redirects with special header because fetch() automatically follows redirects
|
|
67
|
+
// and we want to redirect the user to the actual full page instead
|
|
68
|
+
if ([301, 302, 307, 308].includes(response.status)) {
|
|
69
|
+
response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
|
|
70
|
+
response.headers.delete('Location');
|
|
71
|
+
}
|
|
59
72
|
}
|
|
60
|
-
}
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
return response;
|
|
75
|
+
},
|
|
76
|
+
{ middleware: schema ? [validateBody(schema as unknown as z.ZodObject | z.ZodAny)] : [] }
|
|
77
|
+
)
|
|
64
78
|
/**
|
|
65
79
|
* Custom error handler for the action since validateBody() throws a HTTPResponseException
|
|
66
80
|
*/
|
|
67
81
|
.errorHandler(async (c: HS.Context, err: HTTPResponseException) => {
|
|
68
|
-
const data =
|
|
69
|
-
|
|
82
|
+
const data =
|
|
83
|
+
(c.vars.body as HS.InferActionData<S>) || formDataToJSON(await c.req.formData()) || {};
|
|
84
|
+
const error = (err._error as ZodValidationError) || err;
|
|
70
85
|
|
|
71
86
|
// Set the status to 400 if it's a ZodValidationError, otherwise 500 (Error thrown by user POST handler)
|
|
72
87
|
c.res.status = err._error ? 400 : 500;
|
|
73
88
|
|
|
74
89
|
log('errorHandler', { data, error });
|
|
75
90
|
|
|
76
|
-
return await returnHTMLResponse(
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
return await returnHTMLResponse(
|
|
92
|
+
c,
|
|
93
|
+
() => {
|
|
94
|
+
return _errorHandler ? _errorHandler(c, { data, error }) : api.render(c, { data, error });
|
|
95
|
+
},
|
|
96
|
+
actionResponseOptions(c.res.status ?? 400)
|
|
97
|
+
);
|
|
79
98
|
});
|
|
80
99
|
|
|
81
100
|
// Set the name of the action for the route
|
|
@@ -84,6 +103,12 @@ export function createAction<S extends z.ZodType>(
|
|
|
84
103
|
const api: HS.Action<S | undefined> = {
|
|
85
104
|
_kind: 'hsAction',
|
|
86
105
|
_config: route._config,
|
|
106
|
+
get _serverConfig() {
|
|
107
|
+
return route._serverConfig;
|
|
108
|
+
},
|
|
109
|
+
set _serverConfig(config: HS.Config | undefined) {
|
|
110
|
+
route._serverConfig = config;
|
|
111
|
+
},
|
|
87
112
|
_path() {
|
|
88
113
|
return path;
|
|
89
114
|
},
|
|
@@ -111,7 +136,10 @@ export function createAction<S extends z.ZodType>(
|
|
|
111
136
|
*/
|
|
112
137
|
render(c, props) {
|
|
113
138
|
const formContent = api._form ? api._form(c, props || {}) : null;
|
|
114
|
-
return formContent
|
|
139
|
+
return formContent
|
|
140
|
+
? html`<hs-action url="${this._path()}">${formContent}</hs-action
|
|
141
|
+
>${actionsClientJS.renderScriptTag()}`
|
|
142
|
+
: null;
|
|
115
143
|
},
|
|
116
144
|
errorHandler(handler) {
|
|
117
145
|
_errorHandler = handler;
|
|
@@ -129,4 +157,4 @@ export function createAction<S extends z.ZodType>(
|
|
|
129
157
|
};
|
|
130
158
|
|
|
131
159
|
return api;
|
|
132
|
-
}
|
|
160
|
+
}
|