@storybook-astro/framework 1.0.3 → 1.1.0

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 (62) hide show
  1. package/dist/{chunk-7GHEQUPV.js → chunk-POHTFYST.js} +46 -8
  2. package/dist/chunk-POHTFYST.js.map +1 -0
  3. package/dist/chunk-T7NWIO5S.js +220 -0
  4. package/dist/chunk-T7NWIO5S.js.map +1 -0
  5. package/dist/{chunk-C5OH4VBR.js → chunk-V76WSNSP.js} +124 -47
  6. package/dist/chunk-V76WSNSP.js.map +1 -0
  7. package/dist/{chunk-KSDXET2L.js → chunk-VPJDFGB5.js} +444 -60
  8. package/dist/chunk-VPJDFGB5.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 +75 -15
  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/viteStorybookAstroMiddlewarePlugin.ts +40 -8
  53. package/src/viteStorybookAstroRendererPlugin.ts +45 -0
  54. package/src/vitest/config.ts +45 -4
  55. package/src/vitest/global-setup.ts +45 -0
  56. package/dist/chunk-7GHEQUPV.js.map +0 -1
  57. package/dist/chunk-C5OH4VBR.js.map +0 -1
  58. package/dist/chunk-KSDXET2L.js.map +0 -1
  59. package/dist/middleware.d.ts +0 -26
  60. package/src/msw-helpers.ts +0 -1
  61. package/src/msw.ts +0 -58
  62. /package/dist/{viteStorybookAstroMiddlewarePlugin-NP2E52IC.js.map → viteStorybookAstroMiddlewarePlugin-2EFKTECT.js.map} +0 -0
package/src/middleware.ts CHANGED
@@ -4,8 +4,7 @@ import type { Integration } from './integrations/index.ts';
4
4
  import type { SanitizationOptions } from './lib/sanitization.ts';
5
5
  import { resolveSanitizationOptions, sanitizeRenderPayload } from './lib/sanitization.ts';
6
6
  import { resolveStoryModuleMock, withStoryModuleMocks } from './module-mocks.ts';
7
- import { applyMswHandlers } from './msw.ts';
8
- import { selectStoryRules } from './rules.ts';
7
+ import { selectStoryRules, withStoryRuleCleanups } from './rules.ts';
9
8
  import type { RenderStoryInput } from './types.ts';
10
9
  import { addRenderers, resolveClientModules } from 'virtual:astro-container-renderers';
11
10
 
@@ -33,7 +32,6 @@ export type HandlerProps = {
33
32
  };
34
33
 
35
34
  type HandlerFactoryOptions = {
36
- mode?: 'development' | 'production';
37
35
  sanitization?: SanitizationOptions;
38
36
  rulesConfigFilePath?: string;
39
37
  resolveRulesConfigModule?: ResolveRulesConfigModule;
@@ -41,7 +39,52 @@ type HandlerFactoryOptions = {
41
39
  };
42
40
 
43
41
  export async function handlerFactory(_integrations: Integration[], options?: HandlerFactoryOptions) {
44
- const mode = options?.mode ?? 'development';
42
+ // Inject a passthrough image service before any component renders.
43
+ //
44
+ // AstroContainer has no image service configuration API, and the default
45
+ // getConfiguredImageService() tries to dynamically import "virtual:image-service"
46
+ // which fails in astro6/Vite 7's module runner. Even when it succeeds (astro5),
47
+ // the noop service still routes through /_image?href=... URLs that the Storybook
48
+ // dev server cannot serve.
49
+ //
50
+ // Pre-populating globalThis.astroAsset.imageService bypasses the dynamic import
51
+ // entirely. Our service returns the direct /@fs/... Vite URL from the ImageMetadata
52
+ // object, which Vite can serve as a static asset in the browser.
53
+ if (!globalThis.astroAsset) {
54
+ (globalThis as Record<string, unknown>).astroAsset = {};
55
+ }
56
+ (globalThis.astroAsset as Record<string, unknown>).imageService = {
57
+ propertiesToHash: ['src'],
58
+ validateOptions(options: Record<string, unknown>) {
59
+ return options;
60
+ },
61
+ getURL(options: { src: unknown }) {
62
+ const src = options.src;
63
+
64
+ if (src != null && typeof src === 'object' && 'src' in src && typeof (src as Record<string, unknown>).src === 'string') {
65
+ // ImageMetadata object — return the /@fs/... Vite URL directly
66
+ return (src as Record<string, unknown>).src as string;
67
+ }
68
+
69
+ return typeof src === 'string' ? src : '';
70
+ },
71
+ getHTMLAttributes(options: Record<string, unknown>) {
72
+ const { src, width, height, format, quality, densities, widths, formats, layout, priority, fit, position, background, ...attrs } = options;
73
+ const srcObj = src != null && typeof src === 'object' ? src as Record<string, unknown> : null;
74
+
75
+ return {
76
+ ...attrs,
77
+ width: width ?? srcObj?.width,
78
+ height: height ?? srcObj?.height,
79
+ loading: (attrs.loading as string | undefined) ?? 'lazy',
80
+ decoding: (attrs.decoding as string | undefined) ?? 'async',
81
+ };
82
+ },
83
+ getSrcSet() {
84
+ return [];
85
+ }
86
+ };
87
+
45
88
  const container = await AstroContainer.create({
46
89
  // Somewhat hacky way to force client-side Storybook's Vite to resolve modules properly
47
90
  resolve: async (specifier) => {
@@ -116,33 +159,32 @@ export async function handlerFactory(_integrations: Integration[], options?: Han
116
159
  const selectedRules = await selectStoryRules({
117
160
  configModule: rulesConfigModule,
118
161
  configFilePath: options?.rulesConfigFilePath,
119
- mode,
120
162
  story: data.story
121
163
  });
122
164
 
123
- await applyMswHandlers(selectedRules.mswHandlers);
124
-
125
- return withStoryModuleMocks(selectedRules.moduleMocks, async () => {
126
- const patchedComponent = await loadPatchedComponent(
127
- data.component,
128
- selectedRules.moduleMocks.size === 0
129
- );
130
- const processedArgs = await processImageMetadata(data.args ?? {});
131
- const sanitizedPayload = sanitizeRenderPayload(
132
- {
133
- args: processedArgs,
134
- slots: data.slots ?? {}
135
- },
136
- sanitizationOptions
137
- );
138
-
139
- return container.renderToString(
140
- patchedComponent as Parameters<typeof container.renderToString>[0],
141
- {
142
- props: sanitizedPayload.args,
143
- slots: sanitizedPayload.slots
144
- }
145
- );
165
+ return withStoryRuleCleanups(selectedRules.cleanups, async () => {
166
+ return withStoryModuleMocks(selectedRules.moduleMocks, async () => {
167
+ const patchedComponent = await loadPatchedComponent(
168
+ data.component,
169
+ selectedRules.moduleMocks.size === 0
170
+ );
171
+ const processedArgs = await processImageMetadata(data.args ?? {});
172
+ const sanitizedPayload = sanitizeRenderPayload(
173
+ {
174
+ args: processedArgs,
175
+ slots: data.slots ?? {}
176
+ },
177
+ sanitizationOptions
178
+ );
179
+
180
+ return container.renderToString(
181
+ patchedComponent as Parameters<typeof container.renderToString>[0],
182
+ {
183
+ props: sanitizedPayload.args,
184
+ slots: sanitizedPayload.slots
185
+ }
186
+ );
187
+ });
146
188
  });
147
189
  };
148
190
 
@@ -194,7 +236,11 @@ async function processImageMetadata(
194
236
 
195
237
  for (const [key, value] of Object.entries(args)) {
196
238
  if (isImageMetadata(value)) {
197
- processed[key] = convertImageMetadataToUrl(value);
239
+ // Keep ImageMetadata as a plain object — Astro's image service checks
240
+ // isESMImportedImage (typeof src === 'object') and skips the /@fs/ string
241
+ // validation that throws LocalImageUsedWrongly. Converting to a URL string
242
+ // causes that error when the string starts with /@fs/.
243
+ processed[key] = value;
198
244
 
199
245
  continue;
200
246
  }
@@ -203,7 +249,7 @@ async function processImageMetadata(
203
249
  processed[key] = await Promise.all(
204
250
  value.map(async (item) => {
205
251
  if (isImageMetadata(item)) {
206
- return convertImageMetadataToUrl(item);
252
+ return item;
207
253
  }
208
254
 
209
255
  if (isRecord(item)) {
@@ -237,20 +283,6 @@ function isImageMetadata(value: unknown): value is Record<string, unknown> {
237
283
  );
238
284
  }
239
285
 
240
- function convertImageMetadataToUrl(imageMetadata: Record<string, unknown>): string {
241
- const src = imageMetadata.src;
242
- const fsPath = imageMetadata.fsPath;
243
-
244
- if (typeof src === 'string') {
245
- return src;
246
- }
247
-
248
- if (typeof fsPath === 'string') {
249
- return fsPath;
250
- }
251
-
252
- return String(imageMetadata);
253
- }
254
286
 
255
287
  function isRecord(value: unknown): value is Record<string, unknown> {
256
288
  return typeof value === 'object' && value !== null;
@@ -0,0 +1,7 @@
1
+ import type { StorybookConfig } from '../types.ts';
2
+
3
+ export function defineMain(config: StorybookConfig): StorybookConfig {
4
+ return config;
5
+ }
6
+
7
+ export type { StorybookConfig };
package/src/preset.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { StorybookConfigVite, FrameworkOptions } from './types.ts';
2
2
  import { vitePluginStorybookAstroMiddleware } from './viteStorybookAstroMiddlewarePlugin.ts';
3
3
  import { viteStorybookRendererFallbackPlugin } from './viteStorybookRendererFallbackPlugin.ts';
4
+ import { viteStorybookAstroRendererPlugin } from './viteStorybookAstroRendererPlugin.ts';
4
5
  import { vitePluginAstroComponentMarker } from './vitePluginAstroComponentMarker.ts';
5
6
  import { vitePluginAstroBuildPrerender } from './vitePluginAstroBuildPrerender.ts';
7
+ import { vitePluginAstroBuildServer } from './vitePluginAstroBuildServer.ts';
8
+ import { vitePluginAstroIntegrationOptsFallback } from './vitePluginAstroIntegrationOptsFallback.ts';
6
9
  import { vitePluginAstroVueFallback } from './vitePluginAstroVueFallback.ts';
7
10
  import { resolveSanitizationOptions } from './lib/sanitization.ts';
8
11
  import { mergeWithAstroConfig } from './vitePluginAstro.ts';
@@ -14,8 +17,6 @@ export const core = {
14
17
 
15
18
  export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { configType, presets }) => {
16
19
  const options = await presets.apply<FrameworkOptions>('frameworkOptions');
17
- const { vitePlugin: storybookAstroMiddlewarePlugin, viteConfig } =
18
- await vitePluginStorybookAstroMiddleware(options);
19
20
 
20
21
  if (!config.plugins) {
21
22
  config.plugins = [];
@@ -23,22 +24,52 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
23
24
 
24
25
  const integrations = options.integrations ?? [];
25
26
  const resolveFrom = options.resolveFrom ?? process.cwd();
27
+ const renderMode = options.renderMode ?? 'server';
26
28
  const mode = configType === 'DEVELOPMENT' ? 'development' : 'production';
27
29
  const command = configType === 'DEVELOPMENT' ? 'serve' : 'build';
28
30
 
29
31
  resolveSanitizationOptions(options.sanitization);
30
32
 
33
+ config.envPrefix = mergeEnvPrefixes(config.envPrefix, 'STORYBOOK_');
34
+
35
+ const { vitePlugin: storybookAstroMiddlewarePlugin, viteConfig } =
36
+ await vitePluginStorybookAstroMiddleware(options);
37
+
31
38
  config.plugins.push(
32
- storybookAstroMiddlewarePlugin,
33
39
  viteStorybookRendererFallbackPlugin(integrations),
40
+ viteStorybookAstroRendererPlugin({
41
+ mode,
42
+ renderMode,
43
+ server: options.server
44
+ }),
34
45
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
46
  vitePluginAstroComponentMarker() as any,
36
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
- vitePluginAstroBuildPrerender(options) as any,
47
+ vitePluginAstroIntegrationOptsFallback(),
38
48
  vitePluginAstroVueFallback(),
39
- ...viteConfig.plugins
40
49
  );
41
50
 
51
+ if (configType === 'DEVELOPMENT') {
52
+ config.plugins.push(storybookAstroMiddlewarePlugin, ...viteConfig.plugins);
53
+ } else if (renderMode === 'static') {
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ config.plugins.push(vitePluginAstroBuildPrerender(options) as any);
56
+ } else {
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ config.plugins.push(vitePluginAstroBuildServer(options) as any);
59
+ }
60
+
61
+ if (configType !== 'DEVELOPMENT') {
62
+ config.build = {
63
+ ...(config.build ?? {}),
64
+ manifest: true
65
+ };
66
+
67
+ config.build.rollupOptions = {
68
+ ...(config.build.rollupOptions ?? {}),
69
+ preserveEntrySignatures: 'strict'
70
+ };
71
+ }
72
+
42
73
  // Add React/ReactDOM aliases for storybook-solidjs compatibility
43
74
  if (!config.resolve) {
44
75
  config.resolve = {};
@@ -59,17 +90,18 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
59
90
 
60
91
  const finalConfig = await mergeWithAstroConfig(config, integrations, resolveFrom, mode, command);
61
92
 
62
- // Exclude @astrojs/vue from dependency optimization because it imports
63
- // virtual modules that esbuild cannot resolve (virtual:@astrojs/vue/app).
64
- // This must be done after mergeWithAstroConfig to avoid being overwritten.
93
+ // Exclude Astro integration packages from dependency optimization because
94
+ // they import virtual modules that esbuild cannot resolve.
65
95
  if (!finalConfig.optimizeDeps) {
66
96
  finalConfig.optimizeDeps = {};
67
97
  }
68
98
  if (!finalConfig.optimizeDeps.exclude) {
69
99
  finalConfig.optimizeDeps.exclude = [];
70
100
  }
71
- if (!finalConfig.optimizeDeps.exclude.includes('@astrojs/vue')) {
72
- finalConfig.optimizeDeps.exclude.push('@astrojs/vue');
101
+ for (const pkg of ['@astrojs/vue', '@astrojs/react', '@astrojs/preact']) {
102
+ if (!finalConfig.optimizeDeps.exclude.includes(pkg)) {
103
+ finalConfig.optimizeDeps.exclude.push(pkg);
104
+ }
73
105
  }
74
106
  // Exclude the renderer from Vite's esbuild pre-bundler so that
75
107
  // import.meta.hot is preserved in the preview iframe. When installed
@@ -80,20 +112,48 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
80
112
  if (!finalConfig.optimizeDeps.exclude.includes('@storybook-astro/renderer')) {
81
113
  finalConfig.optimizeDeps.exclude.push('@storybook-astro/renderer');
82
114
  }
83
- // Mark Vue virtual modules as external so esbuild doesn't try to resolve them
115
+ // Mark integration virtual modules as external so the dep bundler doesn't
116
+ // try to resolve them (they are Vite virtual modules with no real package).
117
+ // Set both esbuildOptions (Vite ≤7) and rolldownOptions (Vite 8+, Rolldown)
118
+ // so the correct key is populated regardless of Vite version.
119
+ const integrationVirtualModules = [
120
+ 'virtual:@astrojs/vue/app',
121
+ 'virtual:astro:vue-app',
122
+ 'astro:react:opts',
123
+ 'astro:preact:opts'
124
+ ];
125
+
126
+ // Vite ≤7 (esbuild-based optimizer)
84
127
  if (!finalConfig.optimizeDeps.esbuildOptions) {
85
128
  finalConfig.optimizeDeps.esbuildOptions = {};
86
129
  }
87
130
  if (!finalConfig.optimizeDeps.esbuildOptions.external) {
88
131
  finalConfig.optimizeDeps.esbuildOptions.external = [];
89
132
  }
90
- const vueVirtualModules = ['virtual:@astrojs/vue/app', 'virtual:astro:vue-app'];
91
-
92
- for (const mod of vueVirtualModules) {
133
+ for (const mod of integrationVirtualModules) {
93
134
  if (!finalConfig.optimizeDeps.esbuildOptions.external.includes(mod)) {
94
135
  finalConfig.optimizeDeps.esbuildOptions.external.push(mod);
95
136
  }
96
137
  }
97
138
 
139
+ // Vite 8+ (Rolldown-based optimizer) — same semantics, different key
140
+ // Use a loose cast because rolldownOptions is absent from Vite <8 types.
141
+ const optimizeDepsMut = finalConfig.optimizeDeps as Record<string, unknown>;
142
+ const rolldownOpts = (optimizeDepsMut.rolldownOptions ?? {}) as { external?: string[] };
143
+
144
+ rolldownOpts.external = Array.from(
145
+ new Set([...(rolldownOpts.external ?? []), ...integrationVirtualModules])
146
+ );
147
+ optimizeDepsMut.rolldownOptions = rolldownOpts;
148
+
98
149
  return finalConfig;
99
150
  };
151
+
152
+ function mergeEnvPrefixes(
153
+ existing: string | string[] | undefined,
154
+ additionalPrefix: string
155
+ ): string[] {
156
+ const prefixes = Array.isArray(existing) ? existing : existing ? [existing] : [];
157
+
158
+ return Array.from(new Set([...prefixes, additionalPrefix]));
159
+ }
@@ -0,0 +1,82 @@
1
+ import type {
2
+ RenderComponentInput,
3
+ RenderPromise,
4
+ RenderResponseMessage
5
+ } from '@storybook-astro/renderer/types';
6
+
7
+ const pendingMessages = new Map<string, RenderPromise>();
8
+ const ASTRO_SERVER_UNAVAILABLE_ERROR_NAME = 'AstroRenderServerUnavailableError';
9
+
10
+ export async function render(data: RenderComponentInput, timeoutMs = 5000) {
11
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
12
+ const id = crypto.randomUUID();
13
+
14
+ const promise = new Promise<RenderResponseMessage['data']>((resolve, reject) => {
15
+ const timeoutId = setTimeout(() => {
16
+ pendingMessages.delete(id);
17
+
18
+ const error = new Error(
19
+ `Unable to reach Astro rendering server. No render response was received within ${timeoutMs}ms.`
20
+ );
21
+
22
+ error.name = ASTRO_SERVER_UNAVAILABLE_ERROR_NAME;
23
+
24
+ reject(error);
25
+ }, timeoutMs);
26
+
27
+ pendingMessages.set(id, { resolve, reject, timeoutId });
28
+ });
29
+
30
+ import.meta.hot?.send('astro:render:request', {
31
+ ...data,
32
+ id
33
+ });
34
+
35
+ return promise;
36
+ }
37
+
38
+ export function init() {
39
+ import.meta.hot?.on('vite:afterUpdate', (payload) => {
40
+ if (payload.updates.some((update) => isAstroStyleUpdate(update.path))) {
41
+ applyStyles();
42
+ }
43
+ });
44
+
45
+ import.meta.hot?.on('astro:render:response', (data: RenderResponseMessage['data']) => {
46
+ if (!data.id || !pendingMessages.has(data.id)) {
47
+ return;
48
+ }
49
+
50
+ const pendingMessage = pendingMessages.get(data.id);
51
+
52
+ if (!pendingMessage) {
53
+ return;
54
+ }
55
+
56
+ clearTimeout(pendingMessage.timeoutId);
57
+ pendingMessages.delete(data.id);
58
+ pendingMessage.resolve(data);
59
+ });
60
+ }
61
+
62
+ export function applyStyles() {
63
+ Array.from(document.querySelectorAll('style[data-vite-dev-id]'))
64
+ .filter((element) => /__vite__updateStyle/.test(element.innerHTML))
65
+ .forEach((element) => {
66
+ const script = document.createElement('script');
67
+
68
+ script.type = 'module';
69
+
70
+ const safeScriptContent = element.innerHTML
71
+ .replaceAll('import.meta.hot.accept(', 'import.meta.hot?.accept(')
72
+ .replaceAll('import.meta.hot.prune(', 'import.meta.hot?.prune(');
73
+
74
+ script.appendChild(document.createTextNode(safeScriptContent));
75
+ document.head.appendChild(script);
76
+ document.head.removeChild(script);
77
+ });
78
+ }
79
+
80
+ function isAstroStyleUpdate(path: string): boolean {
81
+ return /\.astro\?astro&type=style&index=\d+&lang\.(css|scss|sass|less|stylus)$/.test(path);
82
+ }
@@ -0,0 +1,101 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest';
2
+ import { createServerRenderer } from './renderer-server.ts';
3
+
4
+ const renderPayload = {
5
+ component: '/src/components/Button.astro',
6
+ args: { label: 'Click' },
7
+ slots: {},
8
+ story: undefined
9
+ };
10
+
11
+ describe('createServerRenderer', () => {
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ test('sends bearer auth header with default authorization header', async () => {
18
+ const fetchMock = vi.fn(async () => ({
19
+ ok: true,
20
+ status: 200,
21
+ statusText: 'OK',
22
+ text: async () => '<div>rendered</div>'
23
+ }));
24
+
25
+ vi.stubGlobal('fetch', fetchMock);
26
+ vi.stubGlobal('crypto', { randomUUID: () => 'uuid-1' });
27
+
28
+ const renderer = createServerRenderer({
29
+ serverUrl: 'http://localhost:4000',
30
+ authToken: 'secret'
31
+ });
32
+
33
+ await expect(renderer.render(renderPayload, 1000)).resolves.toEqual({
34
+ id: 'uuid-1',
35
+ html: '<div>rendered</div>'
36
+ });
37
+
38
+ expect(fetchMock).toHaveBeenCalledTimes(1);
39
+ expect(fetchMock).toHaveBeenCalledWith(
40
+ 'http://localhost:4000/render',
41
+ expect.objectContaining({
42
+ method: 'POST',
43
+ headers: {
44
+ 'content-type': 'application/json',
45
+ authorization: 'Bearer secret'
46
+ }
47
+ })
48
+ );
49
+ });
50
+
51
+ test('uses custom auth header without bearer prefix', async () => {
52
+ const fetchMock = vi.fn(async () => ({
53
+ ok: true,
54
+ status: 200,
55
+ statusText: 'OK',
56
+ text: async () => '<div>ok</div>'
57
+ }));
58
+
59
+ vi.stubGlobal('fetch', fetchMock);
60
+ vi.stubGlobal('crypto', { randomUUID: () => 'uuid-2' });
61
+
62
+ const renderer = createServerRenderer({
63
+ serverUrl: 'http://localhost:5000',
64
+ authToken: 'token-123',
65
+ authHeader: 'x-storybook-token'
66
+ });
67
+
68
+ await renderer.render(renderPayload, 1000);
69
+
70
+ expect(fetchMock).toHaveBeenCalledWith(
71
+ 'http://localhost:5000/render',
72
+ expect.objectContaining({
73
+ headers: {
74
+ 'content-type': 'application/json',
75
+ 'x-storybook-token': 'token-123'
76
+ }
77
+ })
78
+ );
79
+ });
80
+
81
+ test('throws a clear error when server rejects auth', async () => {
82
+ vi.stubGlobal('fetch',
83
+ vi.fn(async () => ({
84
+ ok: false,
85
+ status: 401,
86
+ statusText: 'Unauthorized',
87
+ text: async () => ''
88
+ }))
89
+ );
90
+ vi.stubGlobal('crypto', { randomUUID: () => 'uuid-3' });
91
+
92
+ const renderer = createServerRenderer({
93
+ serverUrl: 'http://localhost:6000',
94
+ authToken: 'wrong'
95
+ });
96
+
97
+ await expect(renderer.render(renderPayload, 1000)).rejects.toThrow(
98
+ 'Astro rendering server rejected the request with 401.'
99
+ );
100
+ });
101
+ });
@@ -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
+ }