@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.
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "permissions": {
3
- "allow": [
4
- "Bash(bun test:*)"
5
- ]
3
+ "allow": ["Bash(bun test:*)"]
6
4
  }
7
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -1,6 +1,7 @@
1
1
  import { test, expect, describe } from 'bun:test';
2
2
  import { createAction } from './actions';
3
- import { html, render, type HSHtml } from '@hyperspan/html';
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
- }).form((c) => {
15
- return html`
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
- }).post(async (c, { data }) => {
22
- return c.res.html(`
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
- }).form((c) => {
46
- return html`
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
- }).post(async (c, { data }) => {
53
- return c.res.html(`
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
- }).form((c) => {
78
- return html`
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
- }).post(async (c, { data }) => {
85
- return html`<p>Hello, ${data?.name}!</p>`;
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
- }).form((c, { error }) => {
106
- if (error) {
107
- return html`
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
- return html`
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
- }).post(async (c, { data }) => {
119
- throw new Error('Test error');
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
- }).form((c, { data }) => {
148
- return html`
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
- }).post(async (c, { data }) => {
156
- return c.res.html(`
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
- }).form((c, { data, error }) => {
205
- return html`
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
- }).post(async (c, { data }) => {
214
- return c.res.html(`
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
- }).form((c, { data, error }) => {
252
- return html`
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
- }).post(async (c, { data }) => {
261
- return html`
262
- <p>Hello, ${data?.name}!</p>
263
- <p>Your email is ${data?.email}.</p>
264
- `;
265
- }).errorHandler(async (c, { data, error }) => {
266
- return html`
267
- <p>Caught error in custom error handler: ${error?.message}</p>
268
- <p>Data: ${JSON.stringify(data)}</p>
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(import.meta.resolve('./client/_hs/hyperspan-actions.client'));
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
- params: { name: string; schema: S }
28
- ): HS.Action<S>;
29
- export function createAction(
30
- params: { name: string; schema?: undefined }
31
- ): HS.Action<undefined>;
32
- export function createAction<S extends z.ZodType>(
33
- params: { name: string; schema?: S }
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(async (c: HS.Context) => {
44
- if (!_handler) {
45
- throw new Error('Action POST handler not set! Every action must have a POST handler.');
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
- const data = c.vars.body as HS.InferActionData<S> || formDataToJSON(await c.req.formData()) || {};
49
- log('POST handler', { data });
50
- const response = await _handler(c, { data });
51
- log('POST handler response', { response });
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
- if (response instanceof Response) {
54
- // Replace redirects with special header because fetch() automatically follows redirects
55
- // and we want to redirect the user to the actual full page instead
56
- if ([301, 302, 307, 308].includes(response.status)) {
57
- response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
58
- response.headers.delete('Location');
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
- return response;
63
- }, { middleware: schema ? [validateBody(schema)] : [] })
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 = c.vars.body as HS.InferActionData<S> || formDataToJSON(await c.req.formData()) || {};
69
- const error = err._error as ZodValidationError || err;
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(c, () => {
77
- return _errorHandler ? _errorHandler(c, { data, error }) : api.render(c, { data, error });
78
- }, { status: 400 });
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 ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${actionsClientJS.renderScriptTag()}` : null;
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
+ }