@storybook-astro/framework 1.0.3 → 1.1.1

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.
Files changed (63) hide show
  1. package/dist/{chunk-KSDXET2L.js → chunk-4HECE7IW.js} +477 -61
  2. package/dist/chunk-4HECE7IW.js.map +1 -0
  3. package/dist/{chunk-7GHEQUPV.js → chunk-POHTFYST.js} +46 -8
  4. package/dist/chunk-POHTFYST.js.map +1 -0
  5. package/dist/chunk-T7NWIO5S.js +220 -0
  6. package/dist/chunk-T7NWIO5S.js.map +1 -0
  7. package/dist/{chunk-C5OH4VBR.js → chunk-V76WSNSP.js} +124 -47
  8. package/dist/chunk-V76WSNSP.js.map +1 -0
  9. package/dist/index.d.ts +19 -9
  10. package/dist/index.js +10 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/middleware.js +57 -39
  13. package/dist/middleware.js.map +1 -1
  14. package/dist/node/index.d.ts +10 -0
  15. package/dist/node/index.js +10 -0
  16. package/dist/node/index.js.map +1 -0
  17. package/dist/preset.d.ts +1 -1
  18. package/dist/preset.js +3 -3
  19. package/dist/testing.js +12 -64
  20. package/dist/testing.js.map +1 -1
  21. package/dist/{types-CHTsRtA7.d.ts → types-Cvor6Tyi.d.ts} +21 -5
  22. package/dist/{viteStorybookAstroMiddlewarePlugin-NP2E52IC.js → viteStorybookAstroMiddlewarePlugin-2EFKTECT.js} +2 -2
  23. package/dist/vitest/global-setup.js +42 -0
  24. package/dist/vitest/global-setup.js.map +1 -0
  25. package/dist/vitest/index.js +20 -3
  26. package/dist/vitest/index.js.map +1 -1
  27. package/package.json +11 -3
  28. package/src/index.ts +21 -1
  29. package/src/lib/sanitization.ts +104 -0
  30. package/src/middleware.ts +76 -44
  31. package/src/node/index.ts +7 -0
  32. package/src/preset.ts +86 -16
  33. package/src/renderer/renderer-dev.ts +82 -0
  34. package/src/renderer/renderer-server.test.ts +101 -0
  35. package/src/renderer/renderer-server.ts +135 -0
  36. package/src/renderer/renderer-static.ts +62 -0
  37. package/src/rules.test.ts +89 -18
  38. package/src/rules.ts +67 -18
  39. package/src/server/index.ts +111 -0
  40. package/src/testing/renderer-daemon.ts +10 -1
  41. package/src/types.ts +25 -5
  42. package/src/virtual.d.ts +37 -0
  43. package/src/vite/astroFilesVirtualModulePlugin.ts +36 -0
  44. package/src/vite/createVirtualModulePlugin.ts +3 -3
  45. package/src/vite/storybookAstroRulesConfigVirtualModulePlugin.ts +37 -0
  46. package/src/vite/storybookAstroSanitizationConfigVirtualModulePlugin.ts +21 -0
  47. package/src/vite/storybookAstroServerAuthConfigVirtualModulePlugin.test.ts +71 -0
  48. package/src/vite/storybookAstroServerAuthConfigVirtualModulePlugin.ts +42 -0
  49. package/src/vitePluginAstroBuildPrerender.ts +50 -51
  50. package/src/vitePluginAstroBuildServer.ts +289 -0
  51. package/src/vitePluginAstroIntegrationOptsFallback.ts +25 -0
  52. package/src/vitePluginAstroToolbarFallback.ts +38 -0
  53. package/src/viteStorybookAstroMiddlewarePlugin.ts +40 -8
  54. package/src/viteStorybookAstroRendererPlugin.ts +45 -0
  55. package/src/vitest/config.ts +45 -4
  56. package/src/vitest/global-setup.ts +45 -0
  57. package/dist/chunk-7GHEQUPV.js.map +0 -1
  58. package/dist/chunk-C5OH4VBR.js.map +0 -1
  59. package/dist/chunk-KSDXET2L.js.map +0 -1
  60. package/dist/middleware.d.ts +0 -26
  61. package/src/msw-helpers.ts +0 -1
  62. package/src/msw.ts +0 -58
  63. /package/dist/{viteStorybookAstroMiddlewarePlugin-NP2E52IC.js.map → viteStorybookAstroMiddlewarePlugin-2EFKTECT.js.map} +0 -0
@@ -0,0 +1,135 @@
1
+ import type { RenderComponentInput, RenderResponseMessage } from '@storybook-astro/renderer/types';
2
+
3
+ type StorybookImportMetaEnv = ImportMeta & {
4
+ env?: Record<string, string | undefined>;
5
+ };
6
+
7
+ type StorybookGlobalEnv = typeof globalThis & {
8
+ STORYBOOK_ASTRO_SERVER_URL?: string;
9
+ STORYBOOK_ASTRO_SERVER_TOKEN?: string;
10
+ STORYBOOK_ASTRO_SERVER_AUTH_HEADER?: string;
11
+ };
12
+
13
+ type ServerRendererDefaults = {
14
+ serverUrl?: string;
15
+ authToken?: string;
16
+ authHeader?: string;
17
+ };
18
+
19
+ const ASTRO_SERVER_UNAVAILABLE_ERROR_NAME = 'AstroRenderServerUnavailableError';
20
+
21
+ export function createServerRenderer(defaults: ServerRendererDefaults = {}) {
22
+ return {
23
+ render(data: RenderComponentInput, timeoutMs = 5000) {
24
+ return renderWithHttp(data, timeoutMs, defaults);
25
+ },
26
+ init() {
27
+ return;
28
+ },
29
+ applyStyles() {
30
+ return;
31
+ }
32
+ };
33
+ }
34
+
35
+ async function renderWithHttp(
36
+ data: RenderComponentInput,
37
+ timeoutMs: number,
38
+ defaults: ServerRendererDefaults
39
+ ) {
40
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
41
+ const id = crypto.randomUUID();
42
+ const serverUrl = resolveServerUrl(defaults);
43
+ const authToken = resolveAuthToken(defaults);
44
+ const authHeader = resolveAuthHeader(defaults);
45
+ const controller = new AbortController();
46
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
47
+
48
+ try {
49
+ const headers: Record<string, string> = {
50
+ 'content-type': 'application/json'
51
+ };
52
+
53
+ if (authToken) {
54
+ headers[authHeader] =
55
+ authHeader.toLowerCase() === 'authorization' && !authToken.startsWith('Bearer ')
56
+ ? `Bearer ${authToken}`
57
+ : authToken;
58
+ }
59
+
60
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
61
+ const response = await fetch(`${serverUrl}/render`, {
62
+ method: 'POST',
63
+ headers,
64
+ body: JSON.stringify(data),
65
+ signal: controller.signal
66
+ });
67
+
68
+ clearTimeout(timeoutId);
69
+
70
+ if (response.status === 401 || response.status === 403) {
71
+ throw new Error(
72
+ `Astro rendering server rejected the request with ${response.status}. ` +
73
+ `Check STORYBOOK_ASTRO_SERVER_TOKEN and auth header configuration.`
74
+ );
75
+ }
76
+
77
+ if (!response.ok) {
78
+ throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
79
+ }
80
+
81
+ const html = await response.text();
82
+
83
+ return {
84
+ id,
85
+ html
86
+ } satisfies RenderResponseMessage['data'];
87
+ } catch (error) {
88
+ clearTimeout(timeoutId);
89
+
90
+ if (error instanceof Error && error.name === 'AbortError') {
91
+ throw createServerUnavailableError(
92
+ serverUrl,
93
+ `Request timed out after ${timeoutMs}ms while waiting for a render response.`
94
+ );
95
+ }
96
+
97
+ if (error instanceof TypeError) {
98
+ throw createServerUnavailableError(
99
+ serverUrl,
100
+ 'The Astro rendering server is not reachable over HTTP.'
101
+ );
102
+ }
103
+
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ function resolveServerUrl(defaults: ServerRendererDefaults) {
109
+ const envServerUrl = (import.meta as StorybookImportMetaEnv).env?.STORYBOOK_ASTRO_SERVER_URL;
110
+ const globalServerUrl = (globalThis as StorybookGlobalEnv).STORYBOOK_ASTRO_SERVER_URL;
111
+
112
+ return defaults.serverUrl || envServerUrl || globalServerUrl || 'http://localhost:3000';
113
+ }
114
+
115
+ function resolveAuthToken(defaults: ServerRendererDefaults) {
116
+ const envAuthToken = (import.meta as StorybookImportMetaEnv).env?.STORYBOOK_ASTRO_SERVER_TOKEN;
117
+ const globalAuthToken = (globalThis as StorybookGlobalEnv).STORYBOOK_ASTRO_SERVER_TOKEN;
118
+
119
+ return defaults.authToken || envAuthToken || globalAuthToken;
120
+ }
121
+
122
+ function resolveAuthHeader(defaults: ServerRendererDefaults) {
123
+ const envAuthHeader = (import.meta as StorybookImportMetaEnv).env?.STORYBOOK_ASTRO_SERVER_AUTH_HEADER;
124
+ const globalAuthHeader = (globalThis as StorybookGlobalEnv).STORYBOOK_ASTRO_SERVER_AUTH_HEADER;
125
+
126
+ return (defaults.authHeader || envAuthHeader || globalAuthHeader || 'authorization').toLowerCase();
127
+ }
128
+
129
+ function createServerUnavailableError(serverUrl: string, reason: string) {
130
+ const error = new Error(`Unable to reach Astro rendering server at ${serverUrl}. ${reason}`);
131
+
132
+ error.name = ASTRO_SERVER_UNAVAILABLE_ERROR_NAME;
133
+
134
+ return error;
135
+ }
@@ -0,0 +1,62 @@
1
+ import type { RenderComponentInput, RenderResponseMessage } from '@storybook-astro/renderer/types';
2
+
3
+ const PRERENDERED_STORIES_FILE = 'astro-prerendered-stories.json';
4
+
5
+ let prerenderedStoriesPromise: Promise<Record<string, string>> | undefined;
6
+
7
+ export async function render(data: RenderComponentInput) {
8
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
9
+ const id = crypto.randomUUID();
10
+ const storyId = data.story?.id;
11
+
12
+ if (!storyId) {
13
+ throw new Error(
14
+ 'Astro static renderer expected a story id, but none was provided in the render payload.'
15
+ );
16
+ }
17
+
18
+ const prerenderedStories = await loadPrerenderedStories();
19
+ const html = prerenderedStories[storyId];
20
+
21
+ if (html === undefined) {
22
+ throw new Error(
23
+ `No prerendered HTML was found for story "${storyId}". Rebuild Storybook static output.`
24
+ );
25
+ }
26
+
27
+ return {
28
+ id,
29
+ html
30
+ } satisfies RenderResponseMessage['data'];
31
+ }
32
+
33
+ export function init() {
34
+ return;
35
+ }
36
+
37
+ export function applyStyles() {
38
+ return;
39
+ }
40
+
41
+ async function loadPrerenderedStories() {
42
+ if (!prerenderedStoriesPromise) {
43
+ const jsonPath = resolvePrerenderedStoriesUrl();
44
+
45
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
46
+ prerenderedStoriesPromise = fetch(jsonPath).then(async (response) => {
47
+ if (!response.ok) {
48
+ throw new Error(
49
+ `Failed to load ${PRERENDERED_STORIES_FILE}. Received ${response.status} ${response.statusText}.`
50
+ );
51
+ }
52
+
53
+ return (await response.json()) as Record<string, string>;
54
+ });
55
+ }
56
+
57
+ return prerenderedStoriesPromise;
58
+ }
59
+
60
+ function resolvePrerenderedStoriesUrl() {
61
+ return new URL(PRERENDERED_STORIES_FILE, window.location.href).toString();
62
+ }
package/src/rules.test.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { resolve } from 'node:path';
2
- import type { RequestHandler } from 'msw';
3
2
  import { describe, expect, test } from 'vitest';
4
- import { defineStoryRules, selectStoryRules, type StoryRulesConfig } from './rules.ts';
3
+ import {
4
+ defineStoryRules,
5
+ selectStoryRules,
6
+ withStoryRuleCleanups,
7
+ type StoryRulesConfig
8
+ } from './rules.ts';
5
9
 
6
10
  function createRulesConfig(config: StoryRulesConfig) {
7
11
  return {
@@ -13,14 +17,13 @@ describe('story rules', () => {
13
17
  test('returns an empty selection when no rules are configured', async () => {
14
18
  const selection = await selectStoryRules({
15
19
  configModule: undefined,
16
- mode: 'development',
17
20
  story: {
18
21
  id: 'components-card--default'
19
22
  }
20
23
  });
21
24
 
22
25
  expect(selection.moduleMocks.size).toBe(0);
23
- expect(selection.mswHandlers).toEqual([]);
26
+ expect(selection.cleanups).toEqual([]);
24
27
  });
25
28
 
26
29
  test('matches rules against story id and applies module mocks', async () => {
@@ -35,14 +38,13 @@ describe('story rules', () => {
35
38
  }
36
39
  ]
37
40
  }),
38
- mode: 'development',
39
41
  story: {
40
42
  id: 'components-card--default'
41
43
  }
42
44
  });
43
45
 
44
46
  expect(selection.moduleMocks.get('~/lib/api')).toBe('~/lib/api.mock');
45
- expect(selection.mswHandlers).toHaveLength(0);
47
+ expect(selection.cleanups).toHaveLength(0);
46
48
  });
47
49
 
48
50
  test('matches rules against title and story name paths', async () => {
@@ -57,7 +59,6 @@ describe('story rules', () => {
57
59
  }
58
60
  ]
59
61
  }),
60
- mode: 'development',
61
62
  story: {
62
63
  id: 'guides-getting-started--default-state',
63
64
  title: 'Guides/Getting Started',
@@ -80,7 +81,6 @@ describe('story rules', () => {
80
81
  }
81
82
  ]
82
83
  }),
83
- mode: 'development',
84
84
  story: {
85
85
  id: '/story/components-card--default'
86
86
  }
@@ -89,28 +89,102 @@ describe('story rules', () => {
89
89
  expect(selection.moduleMocks.get('~/store')).toBe('~/store.mock');
90
90
  });
91
91
 
92
- test('collects MSW handlers from matching rules', async () => {
93
- const firstHandler = {} as RequestHandler;
94
- const secondHandler = {} as RequestHandler;
92
+ test('collects cleanup functions from matching rules', async () => {
93
+ const cleanup = () => undefined;
95
94
 
96
95
  const selection = await selectStoryRules({
97
96
  configModule: createRulesConfig({
98
97
  rules: [
99
98
  {
100
99
  match: '*',
101
- use: ({ msw }) => {
102
- msw.use(firstHandler, secondHandler);
100
+ use: () => {
101
+ return cleanup;
103
102
  }
104
103
  }
105
104
  ]
106
105
  }),
107
- mode: 'production',
108
106
  story: {
109
107
  id: 'components-card--default'
110
108
  }
111
109
  });
112
110
 
113
- expect(selection.mswHandlers).toEqual([firstHandler, secondHandler]);
111
+ expect(selection.cleanups).toEqual([cleanup]);
112
+ });
113
+
114
+ test('runs cleanups after successful execution in reverse order', async () => {
115
+ const sequence: string[] = [];
116
+
117
+ await withStoryRuleCleanups(
118
+ [
119
+ () => {
120
+ sequence.push('cleanup:first');
121
+ },
122
+ async () => {
123
+ sequence.push('cleanup:second');
124
+ }
125
+ ],
126
+ async () => {
127
+ sequence.push('render');
128
+ }
129
+ );
130
+
131
+ expect(sequence).toEqual(['render', 'cleanup:second', 'cleanup:first']);
132
+ });
133
+
134
+ test('runs cleanups when execution throws', async () => {
135
+ const sequence: string[] = [];
136
+
137
+ await expect(
138
+ withStoryRuleCleanups(
139
+ [
140
+ () => {
141
+ sequence.push('cleanup');
142
+ }
143
+ ],
144
+ async () => {
145
+ sequence.push('render');
146
+ throw new Error('render failed');
147
+ }
148
+ )
149
+ ).rejects.toThrow('render failed');
150
+
151
+ expect(sequence).toEqual(['render', 'cleanup']);
152
+ });
153
+
154
+ test('throws when use returns a non-function value', async () => {
155
+ await expect(
156
+ selectStoryRules({
157
+ configModule: createRulesConfig({
158
+ rules: [
159
+ {
160
+ match: '*',
161
+ use: () => 'nope' as never
162
+ }
163
+ ]
164
+ }),
165
+ story: {
166
+ id: 'components-card--default'
167
+ }
168
+ })
169
+ ).rejects.toThrow('Story rule "use" must return either nothing or a cleanup function.');
170
+ });
171
+
172
+ test('aggregates cleanup failures', async () => {
173
+ await expect(
174
+ withStoryRuleCleanups(
175
+ [
176
+ () => {
177
+ throw new Error('first cleanup failed');
178
+ },
179
+ () => {
180
+ throw new Error('second cleanup failed');
181
+ }
182
+ ],
183
+ async () => undefined
184
+ )
185
+ ).rejects.toMatchObject({
186
+ message: 'Story rule cleanup failed.'
187
+ });
114
188
  });
115
189
 
116
190
  test('resolves relative mock replacements from config file location', async () => {
@@ -128,7 +202,6 @@ describe('story rules', () => {
128
202
  ]
129
203
  }),
130
204
  configFilePath,
131
- mode: 'development',
132
205
  story: {
133
206
  id: 'components-card--default'
134
207
  }
@@ -150,7 +223,6 @@ describe('story rules', () => {
150
223
  }
151
224
  ]
152
225
  }),
153
- mode: 'development',
154
226
  story: {
155
227
  id: 'components-card--default'
156
228
  }
@@ -171,7 +243,6 @@ describe('story rules', () => {
171
243
  }
172
244
  ]
173
245
  }),
174
- mode: 'development',
175
246
  story: {
176
247
  id: 'components-card--default'
177
248
  }
package/src/rules.ts CHANGED
@@ -1,16 +1,11 @@
1
1
  import { dirname, isAbsolute, resolve } from 'node:path';
2
- import type { RequestHandler } from 'msw';
3
2
  import type { RenderStoryInput } from './types.ts';
4
3
 
5
- type StoryMode = 'development' | 'production';
6
- type StoryRuleUseResult = void | Promise<void>;
4
+ export type StoryRuleCleanup = () => void | Promise<void>;
5
+ type StoryRuleUseResult = void | StoryRuleCleanup | Promise<void | StoryRuleCleanup>;
7
6
 
8
7
  export type StoryRuleUseContext = {
9
- mode: StoryMode;
10
8
  story: StoryRuleStory;
11
- msw: {
12
- use: (...handlers: RequestHandler[]) => void;
13
- };
14
9
  mock: (specifier: string, replacement: string) => void;
15
10
  };
16
11
 
@@ -35,18 +30,17 @@ export type StoryRuleStory = {
35
30
  export type StoryRuleSelectionInput = {
36
31
  configModule: unknown;
37
32
  configFilePath?: string;
38
- mode: StoryMode;
39
33
  story?: RenderStoryInput;
40
34
  };
41
35
 
42
36
  export type StoryRuleSelection = {
43
37
  moduleMocks: Map<string, string>;
44
- mswHandlers: RequestHandler[];
38
+ cleanups: StoryRuleCleanup[];
45
39
  };
46
40
 
47
41
  type MutableStoryRuleSelection = {
48
42
  moduleMocks: Map<string, string>;
49
- mswHandlers: RequestHandler[];
43
+ cleanups: StoryRuleCleanup[];
50
44
  };
51
45
 
52
46
  export function defineStoryRules(config: StoryRulesConfig): StoryRulesConfig {
@@ -72,14 +66,8 @@ export async function selectStoryRules(
72
66
  throw new Error('Each story rule "use" entry must be a function.');
73
67
  }
74
68
 
75
- await use({
76
- mode: input.mode,
69
+ const cleanup = await use({
77
70
  story,
78
- msw: {
79
- use: (...handlers) => {
80
- selection.mswHandlers.push(...handlers);
81
- }
82
- },
83
71
  mock: (specifier, replacement) => {
84
72
  const normalizedSpecifier = normalizeMockSpecifier(specifier);
85
73
  const normalizedReplacement = normalizeMockReplacement(replacement, input.configFilePath);
@@ -87,12 +75,73 @@ export async function selectStoryRules(
87
75
  selection.moduleMocks.set(normalizedSpecifier, normalizedReplacement);
88
76
  }
89
77
  });
78
+
79
+ if (cleanup !== undefined) {
80
+ if (typeof cleanup !== 'function') {
81
+ throw new Error('Story rule "use" must return either nothing or a cleanup function.');
82
+ }
83
+
84
+ selection.cleanups.push(cleanup);
85
+ }
90
86
  }
91
87
  }
92
88
 
93
89
  return selection;
94
90
  }
95
91
 
92
+ export async function withStoryRuleCleanups<T>(
93
+ cleanups: StoryRuleCleanup[],
94
+ callback: () => Promise<T>
95
+ ): Promise<T> {
96
+ let result: T | undefined;
97
+ let callbackError: unknown;
98
+
99
+ try {
100
+ result = await callback();
101
+ } catch (error) {
102
+ callbackError = error;
103
+ }
104
+
105
+ try {
106
+ await runStoryRuleCleanups(cleanups);
107
+ } catch (cleanupError) {
108
+ if (callbackError) {
109
+ throw new AggregateError(
110
+ [callbackError, cleanupError],
111
+ 'Story rule execution and cleanup both failed.'
112
+ );
113
+ }
114
+
115
+ throw cleanupError;
116
+ }
117
+
118
+ if (callbackError) {
119
+ throw callbackError;
120
+ }
121
+
122
+ return result as T;
123
+ }
124
+
125
+ export async function runStoryRuleCleanups(cleanups: StoryRuleCleanup[]): Promise<void> {
126
+ const errors: unknown[] = [];
127
+
128
+ for (let index = cleanups.length - 1; index >= 0; index -= 1) {
129
+ try {
130
+ await cleanups[index]();
131
+ } catch (error) {
132
+ errors.push(error);
133
+ }
134
+ }
135
+
136
+ if (errors.length === 1) {
137
+ throw errors[0];
138
+ }
139
+
140
+ if (errors.length > 1) {
141
+ throw new AggregateError(errors, 'Story rule cleanup failed.');
142
+ }
143
+ }
144
+
96
145
  function normalizeRulesConfig(configModule: unknown): StoryRulesConfig {
97
146
  const configExport = getRulesConfigExport(configModule);
98
147
 
@@ -293,7 +342,7 @@ function slugify(input: string): string {
293
342
  function createEmptySelection(): MutableStoryRuleSelection {
294
343
  return {
295
344
  moduleMocks: new Map(),
296
- mswHandlers: []
345
+ cleanups: []
297
346
  };
298
347
  }
299
348
 
@@ -0,0 +1,111 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { Hono } from 'hono';
3
+ import { cors } from 'hono/cors';
4
+ import type { HandlerProps } from '../middleware.ts';
5
+ import { handlerFactory } from '../middleware.ts';
6
+ import astroFiles from 'virtual:astro-files';
7
+ import sanitization from 'virtual:storybook-astro-sanitization-config';
8
+ import storyRulesConfigModule, {
9
+ storybookAstroStoryRulesConfigFilePath
10
+ } from 'virtual:storybook-astro-story-rules-config';
11
+ import {
12
+ storybookAstroServerAuthHeader,
13
+ storybookAstroServerAuthToken
14
+ } from 'virtual:storybook-astro-server-auth-config';
15
+
16
+ const app = new Hono();
17
+ const renderHandlerPromise = createRenderHandler();
18
+
19
+ app.use(
20
+ '*',
21
+ cors({
22
+ origin: '*',
23
+ allowMethods: ['GET', 'POST'],
24
+ allowHeaders: ['Content-Type', storybookAstroServerAuthHeader]
25
+ })
26
+ );
27
+
28
+ app.get('/', async (context) => context.text('OK'));
29
+
30
+ app.post('/render', async (context) => {
31
+ if (!isRequestAuthorized(context.req.header(storybookAstroServerAuthHeader))) {
32
+ return context.text('Unauthorized', 401);
33
+ }
34
+
35
+ const input = (await context.req.json()) as Partial<HandlerProps>;
36
+ const renderHandler = await renderHandlerPromise;
37
+ const html = await renderHandler({
38
+ component: input.component ?? '',
39
+ args: input.args ?? {},
40
+ slots: input.slots ?? {},
41
+ story: input.story
42
+ });
43
+
44
+ return context.text(html);
45
+ });
46
+
47
+ export default app;
48
+
49
+ async function createRenderHandler() {
50
+ return handlerFactory([], {
51
+ sanitization: sanitization ?? undefined,
52
+ rulesConfigFilePath: storybookAstroStoryRulesConfigFilePath,
53
+ resolveRulesConfigModule: () => storyRulesConfigModule,
54
+ loadModule: async (componentId) => {
55
+ const component = astroFiles[componentId as keyof typeof astroFiles];
56
+
57
+ if (!component) {
58
+ throw new Error(
59
+ `Unable to resolve Astro component "${componentId}" in the server build output.`
60
+ );
61
+ }
62
+
63
+ return {
64
+ default: component
65
+ };
66
+ }
67
+ });
68
+ }
69
+
70
+ function isRequestAuthorized(headerValue: string | undefined) {
71
+ if (!storybookAstroServerAuthToken) {
72
+ return true;
73
+ }
74
+
75
+ const normalizedHeaderValue = normalizeHeaderValue(headerValue);
76
+
77
+ if (!normalizedHeaderValue) {
78
+ return false;
79
+ }
80
+
81
+ return isSecureEqual(normalizedHeaderValue, storybookAstroServerAuthToken);
82
+ }
83
+
84
+ function normalizeHeaderValue(value: string | undefined) {
85
+ if (!value) {
86
+ return undefined;
87
+ }
88
+
89
+ const trimmedValue = value.trim();
90
+
91
+ if (!trimmedValue) {
92
+ return undefined;
93
+ }
94
+
95
+ if (storybookAstroServerAuthHeader === 'authorization' && trimmedValue.startsWith('Bearer ')) {
96
+ return trimmedValue.slice('Bearer '.length).trim();
97
+ }
98
+
99
+ return trimmedValue;
100
+ }
101
+
102
+ function isSecureEqual(actual: string, expected: string) {
103
+ const actualBuffer = Buffer.from(actual);
104
+ const expectedBuffer = Buffer.from(expected);
105
+
106
+ if (actualBuffer.length !== expectedBuffer.length) {
107
+ return false;
108
+ }
109
+
110
+ return timingSafeEqual(actualBuffer, expectedBuffer);
111
+ }
@@ -1,5 +1,6 @@
1
1
  import { createServer as createHttpServer } from 'node:http';
2
2
  import type { IncomingMessage } from 'node:http';
3
+ import { existsSync } from 'node:fs';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import type { ViteDevServer } from 'vite';
5
6
  import { createViteServer } from '../viteStorybookAstroMiddlewarePlugin.ts';
@@ -118,7 +119,15 @@ export async function startTestingRendererDaemon(): Promise<RunningDaemon> {
118
119
  renderHandlerPromises.set(resolveFrom, (async () => {
119
120
  const integrations = resolveTestingIntegrationsForRoot(resolveFrom);
120
121
  const viteServer = await getViteServer(resolveFrom);
121
- const middlewareModulePath = fileURLToPath(new URL('../middleware', import.meta.url));
122
+ // In the workspace this file is src/testing/renderer-daemon.ts so
123
+ // '../middleware.ts' resolves to src/middleware.ts (Vite handles .ts).
124
+ // When compiled by tsup, this code lands in a dist/chunk-*.js file so
125
+ // '../middleware.ts' would resolve to framework/middleware.ts which does
126
+ // not exist; fall back to './middleware.js' (sibling in dist/).
127
+ const middlewareSrcPath = fileURLToPath(new URL('../middleware.ts', import.meta.url));
128
+ const middlewareModulePath = existsSync(middlewareSrcPath)
129
+ ? middlewareSrcPath
130
+ : fileURLToPath(new URL('./middleware.js', import.meta.url));
122
131
  const middleware = await runWithWorkingDirectory(resolveFrom, () =>
123
132
  viteServer.ssrLoadModule(middlewareModulePath, {
124
133
  fixStacktrace: true