11import { existsSync } from 'node:fs'
22import type { ServerResponse } from 'node:http'
3- import { createRequire } from 'node:module'
4- import { dirname , join , resolve } from 'node:path'
3+ import { join , resolve } from 'node:path'
54import { fileURLToPath , pathToFileURL } from 'node:url'
65import { mergeRsbuildConfig } from '@rsbuild/core'
76import type { PresetProperty } from 'storybook/internal/types'
@@ -12,29 +11,37 @@ import type { FrameworkOptions, StorybookConfig } from './types'
1211 * own node_modules. This matters because pnpm can install multiple rspeedy
1312 * variants under different peer-dep contexts; the user's `pluginReactLynx`
1413 * (and the rest of their lynx.config) is bound to the rspeedy instance in
15- * THEIR project. If we import the framework's own copy we get a different
16- * module instance and the React Lynx loader/plugin chain silently no-ops
17- * (manifesting as JSX parse errors).
14+ * THEIR project. If we imported the framework's own copy we would get a
15+ * different module instance and the React Lynx loader/plugin chain would
16+ * silently no-op (manifesting as JSX parse errors).
17+ *
18+ * Note: `createRequire(...).resolve('@lynx-js/rspeedy')` would fail with
19+ * `ERR_PACKAGE_PATH_NOT_EXPORTED` because the package's `exports."."`
20+ * defines only an `import` condition (no `require`). That's why we use the
21+ * two-arg form of `import.meta.resolve`, which walks the ESM resolver from
22+ * the user's project root — stable since Node 20.6 (engines.node enforces).
1823 */
1924async function importUserRspeedy (
2025 projectRoot : string ,
2126) : Promise < typeof import ( '@lynx-js/rspeedy' ) > {
22- // `require.resolve('@lynx-js/rspeedy')` fails: the package is pure-ESM
23- // with no CJS `main`. We resolve `./package.json` (which IS in the
24- // package's `exports` field) and then point at the stable ESM entry
25- // `./dist/index.js`. We don't read the exports field at runtime because
26- // it has been stable since 0.10.x and reading + parsing JSON adds nothing
27- // beyond a runtime-detection illusion of safety.
28- const require = createRequire ( join ( projectRoot , 'package.json' ) )
29- const pkgJsonPath = require . resolve ( '@lynx-js/rspeedy/package.json' )
30- const entryAbs = join ( dirname ( pkgJsonPath ) , 'dist/index.js' )
31- return import ( pathToFileURL ( entryAbs ) . href )
27+ const parentUrl = pathToFileURL ( join ( projectRoot , 'package.json' ) ) . href
28+ const entryUrl = import . meta. resolve ( '@lynx-js/rspeedy' , parentUrl )
29+ return import ( entryUrl )
3230}
3331
3432export const previewAnnotations : PresetProperty < 'previewAnnotations' > = async (
3533 input = [ ] ,
3634) => {
37- return [ ...input , fileURLToPath ( import . meta. resolve ( './preview' ) ) ]
35+ // Order matters: `preview-runtime` is async (top-level await from
36+ // @lynx -js/web-core's WASM init) and contributes no annotations, while
37+ // `preview` is sync and contributes `render` / `renderToCanvas`. Both are
38+ // appended AFTER the renderer's defaults so our render wins composeConfigs.
39+ // See the long note at the top of src/preview.ts for background.
40+ return [
41+ ...input ,
42+ fileURLToPath ( import . meta. resolve ( './preview-runtime' ) ) ,
43+ fileURLToPath ( import . meta. resolve ( './preview' ) ) ,
44+ ]
3845}
3946
4047export const core : PresetProperty < 'core' > = async ( config , options ) => {
@@ -84,7 +91,7 @@ export const staticDirs: PresetProperty<'staticDirs'> = async (
8491 if ( lynxConfigPath ) return input
8592
8693 const distDir = join ( projectRoot , 'dist' )
87- const bundlePrefix = frameworkOptions . lynxBundlePrefix ?? '/lynx-bundles'
94+ const bundlePrefix = normalizeBundlePrefix ( frameworkOptions . lynxBundlePrefix )
8895 return [ ...input , { from : distDir , to : bundlePrefix } ]
8996}
9097
@@ -123,6 +130,56 @@ interface RspeedyState {
123130 setupPromise ?: Promise < void >
124131}
125132
133+ const CSS_FILE_RE = / \. (?: c s s | s c s s | s a s s | l e s s | s t y l | s t y l u s ) $ / i
134+
135+ /**
136+ * Normalize a user-supplied URL prefix so downstream code can treat it
137+ * consistently: leading slash, no trailing slash (`/lynx-bundles`). The
138+ * proxy config needs the leading slash and `pathRewrite` pattern, while
139+ * `output.copy.to` wants a relative directory without the leading slash.
140+ * Accepting either form from the user keeps the DX forgiving.
141+ */
142+ function normalizeBundlePrefix ( raw : string | undefined ) : string {
143+ const value = ( raw ?? '/lynx-bundles' ) . trim ( ) || '/lynx-bundles'
144+ const withLeading = value . startsWith ( '/' ) ? value : `/${ value } `
145+ return withLeading . endsWith ( '/' ) && withLeading . length > 1
146+ ? withLeading . slice ( 0 , - 1 )
147+ : withLeading
148+ }
149+
150+ /**
151+ * Return true if the rebuild's modified file set contains at least one
152+ * style file. We walk the rspack Stats/MultiStats and collect
153+ * `compilation.modifiedFiles` (a `ReadonlySet<string>` of absolute paths
154+ * rspack populated from its watcher before the current rebuild ran). Any
155+ * source file change that ends in a CSS-family extension counts — TSX/TS
156+ * edits alone return `false` and let rsbuild's WebSocket HMR handle them.
157+ */
158+ function hasCssChange ( stats : unknown ) : boolean {
159+ // Duck-typed against Rspack.Stats | Rspack.MultiStats to avoid a
160+ // framework-level dependency on @rspack/core just for a type.
161+ const candidates : unknown [ ] = [ ]
162+ const anyStats = stats as {
163+ stats ?: unknown [ ]
164+ compilation ?: { modifiedFiles ?: ReadonlySet < string > }
165+ }
166+ if ( Array . isArray ( anyStats . stats ) ) {
167+ candidates . push ( ...anyStats . stats )
168+ } else {
169+ candidates . push ( anyStats )
170+ }
171+ for ( const s of candidates ) {
172+ const modified = (
173+ s as { compilation ?: { modifiedFiles ?: ReadonlySet < string > } }
174+ ) . compilation ?. modifiedFiles
175+ if ( ! modified ) continue
176+ for ( const file of modified ) {
177+ if ( CSS_FILE_RE . test ( file ) ) return true
178+ }
179+ }
180+ return false
181+ }
182+
126183const RSPEEDY_STATE_KEY = Symbol . for (
127184 'storybook-react-lynx-web-rsbuild.rspeedy.state' ,
128185)
@@ -181,13 +238,19 @@ async function setupRspeedyDev(
181238 environment : [ 'web' ] ,
182239 } )
183240
184- // Drive HMR reload broadcasts from rspeedy's compiler hook. We fire on
185- // every non-first rebuild — the lynx web runtime has no per-asset HMR
186- // (pluginReactLynx disables it for the `web` env, see lynx-stack
187- // packages/rspeedy/plugin-react/src/entry.ts), so any source change
188- // requires a full <lynx-view> reload regardless of which file changed.
189- rspeedy . onDevCompileDone ( ( { isFirstCompile } ) => {
241+ // Drive dev reload broadcasts from rspeedy's compiler hook, but only
242+ // for **CSS-ish** rebuilds. Background-thread JS already has real HMR
243+ // via rsbuild's standard WebSocket client embedded in the web bundle
244+ // (pluginReactLynx only skips its own HMR prepends for the `web` env
245+ // — see lynx-stack packages/rspeedy/plugin-react/src/entry.ts — not
246+ // rsbuild's normal ones), so broadcasting on every rebuild would
247+ // force-reload <lynx-view> and clobber the JS HMR's state
248+ // preservation. CSS edits, on the other hand, have no upstream HMR
249+ // path: we still need to ping preview.ts so it force-refetches the
250+ // template via the `?t=` cache-bust (see note in preview.ts).
251+ rspeedy . onDevCompileDone ( ( { isFirstCompile, stats } ) => {
190252 if ( isFirstCompile ) return
253+ if ( ! hasCssChange ( stats ) ) return
191254 for ( const client of state . sseClients ) {
192255 client . write ( 'data: content-changed\n\n' )
193256 }
@@ -242,7 +305,7 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
242305 typeof framework === 'string' ? { } : framework . options || { }
243306
244307 const projectRoot = resolveProjectRoot ( options . configDir )
245- const bundlePrefix = frameworkOptions . lynxBundlePrefix ?? '/lynx-bundles'
308+ const bundlePrefix = normalizeBundlePrefix ( frameworkOptions . lynxBundlePrefix )
246309 const lynxConfig = findLynxConfig (
247310 projectRoot ,
248311 frameworkOptions . lynxConfigPath ,
@@ -269,6 +332,19 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
269332 changeOrigin : true ,
270333 pathRewrite : { [ `^${ bundlePrefix } ` ] : '' } ,
271334 } ,
335+ // Lynx-emitted static assets (images, fonts, svg) are referenced
336+ // by the .web.bundle as *relative* URLs like `static/image/foo.png`.
337+ // web-core hands those strings to the DOM unchanged, so the browser
338+ // resolves them against `iframe.html` (root-absolute), NOT against
339+ // the bundle URL. We route those specific subpaths to rspeedy's
340+ // dev server to match how build-mode copies them to the root.
341+ //
342+ // Scoped to subpaths lynx actually uses to avoid colliding with
343+ // storybook's own `/static/js`, `/static/css`, `/static/wasm`,
344+ // which are served by the rsbuild dev middleware.
345+ '/static/image' : { target : origin , changeOrigin : true } ,
346+ '/static/font' : { target : origin , changeOrigin : true } ,
347+ '/static/svg' : { target : origin , changeOrigin : true } ,
272348 '/.rspeedy' : {
273349 target : origin ,
274350 changeOrigin : true ,
@@ -304,6 +380,18 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
304380 // Storybook output as static assets. The source directory comes from
305381 // `rspeedy.context.distPath` so a user-customized `output.distPath.root`
306382 // is honored.
383+ //
384+ // Copy layout:
385+ // - `.web.bundle` files → `lynx-bundles/` (isolated prefix; user's
386+ // `parameters.lynx.url` points here).
387+ // - `static/**` → output root, preserving the `static/` prefix. These
388+ // are referenced from inside the bundle as relative paths like
389+ // `static/image/foo.png`, which the browser resolves against
390+ // `iframe.html` → `/static/image/foo.png`. They MUST land at the
391+ // root, not under `lynx-bundles/`, otherwise image/font lookups 404.
392+ // (The collision surface with storybook's own emitted files is
393+ // empty by construction: storybook uses `static/{js,css,wasm}`
394+ // while lynx uses `static/{image,font,svg}`.)
307395 if ( lynxConfig && ! isDev ) {
308396 const rspeedyDistDir = await runRspeedyBuild ( projectRoot , lynxConfig )
309397
@@ -318,7 +406,7 @@ export const rsbuildFinal: StorybookConfig['rsbuildFinal'] = async (
318406 {
319407 from : 'static/**/*' ,
320408 context : rspeedyDistDir ,
321- to : ` ${ bundlePrefix . slice ( 1 ) } /` ,
409+ to : '' ,
322410 } ,
323411 ] ,
324412 } ,
0 commit comments