@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.
- package/dist/{chunk-KSDXET2L.js → chunk-4HECE7IW.js} +477 -61
- package/dist/chunk-4HECE7IW.js.map +1 -0
- 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/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 +11 -3
- 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 +86 -16
- 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/vitePluginAstroToolbarFallback.ts +38 -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
|
@@ -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 {
|
|
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.
|
|
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.
|
|
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
|
|
93
|
-
const
|
|
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: (
|
|
102
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
38
|
+
cleanups: StoryRuleCleanup[];
|
|
45
39
|
};
|
|
46
40
|
|
|
47
41
|
type MutableStoryRuleSelection = {
|
|
48
42
|
moduleMocks: Map<string, string>;
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|