@storybook-astro/framework 1.0.2 → 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.
- package/dist/{chunk-7GHEQUPV.js → chunk-POHTFYST.js} +46 -8
- package/dist/chunk-POHTFYST.js.map +1 -0
- package/dist/chunk-T7NWIO5S.js +220 -0
- package/dist/chunk-T7NWIO5S.js.map +1 -0
- package/dist/{chunk-C5OH4VBR.js → chunk-V76WSNSP.js} +124 -47
- package/dist/chunk-V76WSNSP.js.map +1 -0
- package/dist/{chunk-KSDXET2L.js → chunk-VPJDFGB5.js} +444 -60
- package/dist/chunk-VPJDFGB5.js.map +1 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/middleware.js +57 -39
- package/dist/middleware.js.map +1 -1
- package/dist/node/index.d.ts +10 -0
- package/dist/node/index.js +10 -0
- package/dist/node/index.js.map +1 -0
- package/dist/preset.d.ts +1 -1
- package/dist/preset.js +3 -3
- package/dist/testing.js +12 -64
- package/dist/testing.js.map +1 -1
- package/dist/{types-CHTsRtA7.d.ts → types-Cvor6Tyi.d.ts} +21 -5
- package/dist/{viteStorybookAstroMiddlewarePlugin-NP2E52IC.js → viteStorybookAstroMiddlewarePlugin-2EFKTECT.js} +2 -2
- package/dist/vitest/global-setup.js +42 -0
- package/dist/vitest/global-setup.js.map +1 -0
- package/dist/vitest/index.js +20 -3
- package/dist/vitest/index.js.map +1 -1
- package/package.json +13 -5
- package/src/index.ts +21 -1
- package/src/lib/sanitization.ts +104 -0
- package/src/middleware.ts +76 -44
- package/src/node/index.ts +7 -0
- package/src/preset.ts +75 -15
- package/src/renderer/renderer-dev.ts +82 -0
- package/src/renderer/renderer-server.test.ts +101 -0
- package/src/renderer/renderer-server.ts +135 -0
- package/src/renderer/renderer-static.ts +62 -0
- package/src/rules.test.ts +89 -18
- package/src/rules.ts +67 -18
- package/src/server/index.ts +111 -0
- package/src/testing/renderer-daemon.ts +10 -1
- package/src/types.ts +25 -5
- package/src/virtual.d.ts +37 -0
- package/src/vite/astroFilesVirtualModulePlugin.ts +36 -0
- package/src/vite/createVirtualModulePlugin.ts +3 -3
- package/src/vite/storybookAstroRulesConfigVirtualModulePlugin.ts +37 -0
- package/src/vite/storybookAstroSanitizationConfigVirtualModulePlugin.ts +21 -0
- package/src/vite/storybookAstroServerAuthConfigVirtualModulePlugin.test.ts +71 -0
- package/src/vite/storybookAstroServerAuthConfigVirtualModulePlugin.ts +42 -0
- package/src/vitePluginAstroBuildPrerender.ts +50 -51
- package/src/vitePluginAstroBuildServer.ts +289 -0
- package/src/vitePluginAstroIntegrationOptsFallback.ts +25 -0
- package/src/viteStorybookAstroMiddlewarePlugin.ts +40 -8
- package/src/viteStorybookAstroRendererPlugin.ts +45 -0
- package/src/vitest/config.ts +45 -4
- package/src/vitest/global-setup.ts +45 -0
- package/dist/chunk-7GHEQUPV.js.map +0 -1
- package/dist/chunk-C5OH4VBR.js.map +0 -1
- package/dist/chunk-KSDXET2L.js.map +0 -1
- package/dist/middleware.d.ts +0 -26
- package/src/msw-helpers.ts +0 -1
- package/src/msw.ts +0 -58
- /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 {
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
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
|
-
|
|
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
|
|
63
|
-
// virtual modules that esbuild cannot resolve
|
|
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
|
-
|
|
72
|
-
finalConfig.optimizeDeps.exclude.
|
|
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
|
|
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
|
|
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
|
+
}
|