@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
@@ -160,6 +160,20 @@ export function sanitizeRenderPayload(
160
160
  };
161
161
  }
162
162
 
163
+ export function serializeSanitizationOptions(options?: SanitizationOptions): string {
164
+ if (!options) {
165
+ return 'undefined';
166
+ }
167
+
168
+ assertNoFunctions(options.sanitizeHtml, 'framework.options.sanitization.sanitizeHtml');
169
+
170
+ const state = {
171
+ seen: new WeakSet<object>()
172
+ };
173
+
174
+ return serializeValue(options, 'framework.options.sanitization', state);
175
+ }
176
+
163
177
  function mergeSanitizeHtmlOptions(userOptions?: IOptions): IOptions {
164
178
  const merged: IOptions = {
165
179
  ...DEFAULT_SANITIZE_HTML_OPTIONS,
@@ -287,6 +301,96 @@ function matchesPathPattern(path: string, pattern: string): boolean {
287
301
  return matchSegments(pathSegments, patternSegments);
288
302
  }
289
303
 
304
+ function serializeValue(value: unknown, path: string, state: { seen: WeakSet<object> }): string {
305
+ if (value === null) {
306
+ return 'null';
307
+ }
308
+
309
+ if (value === undefined) {
310
+ return 'undefined';
311
+ }
312
+
313
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
314
+ return JSON.stringify(value);
315
+ }
316
+
317
+ if (value instanceof RegExp) {
318
+ return value.toString();
319
+ }
320
+
321
+ if (Array.isArray(value)) {
322
+ const serializedItems = value.map((item, index) =>
323
+ serializeValue(item, `${path}[${index}]`, state)
324
+ );
325
+
326
+ return `[${serializedItems.join(', ')}]`;
327
+ }
328
+
329
+ if (isRecord(value)) {
330
+ if (state.seen.has(value)) {
331
+ throw new Error(`${path} contains a circular reference.`);
332
+ }
333
+
334
+ state.seen.add(value);
335
+
336
+ const serializedEntries = Object.entries(value)
337
+ .filter(([, nestedValue]) => nestedValue !== undefined)
338
+ .map(([key, nestedValue]) => {
339
+ const serializedNestedValue = serializeValue(nestedValue, `${path}.${key}`, state);
340
+
341
+ return `${JSON.stringify(key)}: ${serializedNestedValue}`;
342
+ });
343
+
344
+ return `{ ${serializedEntries.join(', ')} }`;
345
+ }
346
+
347
+ throw new Error(
348
+ `${path} contains an unsupported value of type ${typeof value}. ` +
349
+ 'Only plain objects, arrays, primitives, and regular expressions are supported.'
350
+ );
351
+ }
352
+
353
+ function assertNoFunctions(value: unknown, path: string): void {
354
+ const state = {
355
+ seen: new WeakSet<object>()
356
+ };
357
+
358
+ assertNoFunctionsRecursive(value, path, state);
359
+ }
360
+
361
+ function assertNoFunctionsRecursive(
362
+ value: unknown,
363
+ path: string,
364
+ state: { seen: WeakSet<object> }
365
+ ): void {
366
+ if (typeof value === 'function') {
367
+ throw new Error(
368
+ `${path} cannot contain functions. ` +
369
+ 'Function-valued sanitization hooks are not supported in framework options.'
370
+ );
371
+ }
372
+
373
+ if (Array.isArray(value)) {
374
+ value.forEach((item, index) => {
375
+ assertNoFunctionsRecursive(item, `${path}[${index}]`, state);
376
+ });
377
+
378
+ return;
379
+ }
380
+
381
+ if (isRecord(value)) {
382
+ if (state.seen.has(value)) {
383
+ return;
384
+ }
385
+
386
+ state.seen.add(value);
387
+
388
+ Object.entries(value).forEach(([key, nestedValue]) => {
389
+ assertNoFunctionsRecursive(nestedValue, `${path}.${key}`, state);
390
+ });
391
+ }
392
+ }
393
+
290
394
  function matchSegments(pathSegments: string[], patternSegments: string[]): boolean {
291
395
  if (patternSegments.length === 0) {
292
396
  return pathSegments.length === 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,21 +1,30 @@
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';
10
+ import { vitePluginAstroToolbarFallback } from './vitePluginAstroToolbarFallback.ts';
7
11
  import { resolveSanitizationOptions } from './lib/sanitization.ts';
8
12
  import { mergeWithAstroConfig } from './vitePluginAstro.ts';
9
13
 
10
14
  export const core = {
11
15
  builder: '@storybook/builder-vite',
12
- renderer: '@storybook-astro/renderer'
16
+ // Use import.meta.resolve so Storybook receives an absolute file:// URL
17
+ // to the renderer preset rather than a bare package specifier. When
18
+ // package managers like pnpm use strict node_modules isolation, bare
19
+ // specifiers are resolved from the *project root*, where the renderer
20
+ // (a dep of this framework, not the user's project) is not hoisted.
21
+ // The absolute URL is resolved from *this* file's location where the
22
+ // renderer is always accessible as a direct dependency.
23
+ renderer: import.meta.resolve('@storybook-astro/renderer')
13
24
  };
14
25
 
15
26
  export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { configType, presets }) => {
16
27
  const options = await presets.apply<FrameworkOptions>('frameworkOptions');
17
- const { vitePlugin: storybookAstroMiddlewarePlugin, viteConfig } =
18
- await vitePluginStorybookAstroMiddleware(options);
19
28
 
20
29
  if (!config.plugins) {
21
30
  config.plugins = [];
@@ -23,22 +32,53 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
23
32
 
24
33
  const integrations = options.integrations ?? [];
25
34
  const resolveFrom = options.resolveFrom ?? process.cwd();
35
+ const renderMode = options.renderMode ?? 'server';
26
36
  const mode = configType === 'DEVELOPMENT' ? 'development' : 'production';
27
37
  const command = configType === 'DEVELOPMENT' ? 'serve' : 'build';
28
38
 
29
39
  resolveSanitizationOptions(options.sanitization);
30
40
 
41
+ config.envPrefix = mergeEnvPrefixes(config.envPrefix, 'STORYBOOK_');
42
+
43
+ const { vitePlugin: storybookAstroMiddlewarePlugin, viteConfig } =
44
+ await vitePluginStorybookAstroMiddleware(options);
45
+
31
46
  config.plugins.push(
32
- storybookAstroMiddlewarePlugin,
33
47
  viteStorybookRendererFallbackPlugin(integrations),
48
+ viteStorybookAstroRendererPlugin({
49
+ mode,
50
+ renderMode,
51
+ server: options.server
52
+ }),
34
53
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
54
  vitePluginAstroComponentMarker() as any,
36
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
- vitePluginAstroBuildPrerender(options) as any,
55
+ vitePluginAstroIntegrationOptsFallback(),
56
+ vitePluginAstroToolbarFallback(),
38
57
  vitePluginAstroVueFallback(),
39
- ...viteConfig.plugins
40
58
  );
41
59
 
60
+ if (configType === 'DEVELOPMENT') {
61
+ config.plugins.push(storybookAstroMiddlewarePlugin, ...viteConfig.plugins);
62
+ } else if (renderMode === 'static') {
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ config.plugins.push(vitePluginAstroBuildPrerender(options) as any);
65
+ } else {
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ config.plugins.push(vitePluginAstroBuildServer(options) as any);
68
+ }
69
+
70
+ if (configType !== 'DEVELOPMENT') {
71
+ config.build = {
72
+ ...(config.build ?? {}),
73
+ manifest: true
74
+ };
75
+
76
+ config.build.rollupOptions = {
77
+ ...(config.build.rollupOptions ?? {}),
78
+ preserveEntrySignatures: 'strict'
79
+ };
80
+ }
81
+
42
82
  // Add React/ReactDOM aliases for storybook-solidjs compatibility
43
83
  if (!config.resolve) {
44
84
  config.resolve = {};
@@ -59,17 +99,18 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
59
99
 
60
100
  const finalConfig = await mergeWithAstroConfig(config, integrations, resolveFrom, mode, command);
61
101
 
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.
102
+ // Exclude Astro integration packages from dependency optimization because
103
+ // they import virtual modules that esbuild cannot resolve.
65
104
  if (!finalConfig.optimizeDeps) {
66
105
  finalConfig.optimizeDeps = {};
67
106
  }
68
107
  if (!finalConfig.optimizeDeps.exclude) {
69
108
  finalConfig.optimizeDeps.exclude = [];
70
109
  }
71
- if (!finalConfig.optimizeDeps.exclude.includes('@astrojs/vue')) {
72
- finalConfig.optimizeDeps.exclude.push('@astrojs/vue');
110
+ for (const pkg of ['@astrojs/vue', '@astrojs/react', '@astrojs/preact']) {
111
+ if (!finalConfig.optimizeDeps.exclude.includes(pkg)) {
112
+ finalConfig.optimizeDeps.exclude.push(pkg);
113
+ }
73
114
  }
74
115
  // Exclude the renderer from Vite's esbuild pre-bundler so that
75
116
  // import.meta.hot is preserved in the preview iframe. When installed
@@ -80,20 +121,49 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
80
121
  if (!finalConfig.optimizeDeps.exclude.includes('@storybook-astro/renderer')) {
81
122
  finalConfig.optimizeDeps.exclude.push('@storybook-astro/renderer');
82
123
  }
83
- // Mark Vue virtual modules as external so esbuild doesn't try to resolve them
124
+ // Mark integration virtual modules as external so the dep bundler doesn't
125
+ // try to resolve them (they are Vite virtual modules with no real package).
126
+ // Set both esbuildOptions (Vite ≤7) and rolldownOptions (Vite 8+, Rolldown)
127
+ // so the correct key is populated regardless of Vite version.
128
+ const integrationVirtualModules = [
129
+ 'virtual:@astrojs/vue/app',
130
+ 'virtual:astro:vue-app',
131
+ 'astro:react:opts',
132
+ 'astro:preact:opts',
133
+ 'astro:toolbar:internal'
134
+ ];
135
+
136
+ // Vite ≤7 (esbuild-based optimizer)
84
137
  if (!finalConfig.optimizeDeps.esbuildOptions) {
85
138
  finalConfig.optimizeDeps.esbuildOptions = {};
86
139
  }
87
140
  if (!finalConfig.optimizeDeps.esbuildOptions.external) {
88
141
  finalConfig.optimizeDeps.esbuildOptions.external = [];
89
142
  }
90
- const vueVirtualModules = ['virtual:@astrojs/vue/app', 'virtual:astro:vue-app'];
91
-
92
- for (const mod of vueVirtualModules) {
143
+ for (const mod of integrationVirtualModules) {
93
144
  if (!finalConfig.optimizeDeps.esbuildOptions.external.includes(mod)) {
94
145
  finalConfig.optimizeDeps.esbuildOptions.external.push(mod);
95
146
  }
96
147
  }
97
148
 
149
+ // Vite 8+ (Rolldown-based optimizer) — same semantics, different key
150
+ // Use a loose cast because rolldownOptions is absent from Vite <8 types.
151
+ const optimizeDepsMut = finalConfig.optimizeDeps as Record<string, unknown>;
152
+ const rolldownOpts = (optimizeDepsMut.rolldownOptions ?? {}) as { external?: string[] };
153
+
154
+ rolldownOpts.external = Array.from(
155
+ new Set([...(rolldownOpts.external ?? []), ...integrationVirtualModules])
156
+ );
157
+ optimizeDepsMut.rolldownOptions = rolldownOpts;
158
+
98
159
  return finalConfig;
99
160
  };
161
+
162
+ function mergeEnvPrefixes(
163
+ existing: string | string[] | undefined,
164
+ additionalPrefix: string
165
+ ): string[] {
166
+ const prefixes = Array.isArray(existing) ? existing : existing ? [existing] : [];
167
+
168
+ return Array.from(new Set([...prefixes, additionalPrefix]));
169
+ }
@@ -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
+ });