@wular/pnext 0.0.24 → 0.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 (54) hide show
  1. package/bin/pnext +1 -1
  2. package/package.json +2 -2
  3. package/reference/data/bench.json +398 -223
  4. package/reference/getting-started.md +1 -1
  5. package/reference/navigation.md +2 -2
  6. package/src/api/client-navigation.ts +7 -5
  7. package/src/cli/adapters/vercel-warm.ts +120 -28
  8. package/src/cli/build.ts +56 -15
  9. package/src/cli/create.ts +1 -1
  10. package/src/cli/dev.ts +2 -1
  11. package/src/cli/index.ts +146 -10
  12. package/src/cli/migrate/report.ts +1 -1
  13. package/src/cli/serve/pipeline.ts +23 -6
  14. package/src/cli/serve/ui.ts +1 -1
  15. package/src/cli/start.ts +2 -1
  16. package/src/cli/typegen.ts +5 -2
  17. package/src/client/build.ts +217 -58
  18. package/src/client/chunk-fold.ts +7 -1
  19. package/src/client/entry.ts +44 -7
  20. package/src/client/router/runtime.ts +1099 -236
  21. package/src/client/router/types.ts +15 -0
  22. package/src/compat/actions/client-plugin.ts +10 -0
  23. package/src/compat/actions/rewrite.ts +1 -0
  24. package/src/compat/bundler/optimize-package-imports.ts +375 -28
  25. package/src/compat/client/navigation-scroll.ts +29 -3
  26. package/src/compat/client/optimistic-routing.ts +2 -2
  27. package/src/compat/client/segment-cache-policy.ts +4 -18
  28. package/src/compat/client/segment-cache.ts +104 -0
  29. package/src/compat/client/segment-prefetch.ts +6 -6
  30. package/src/compat/next/config-loader.ts +6 -4
  31. package/src/compat/next/navigation.ts +16 -2
  32. package/src/compat/pages/router.ts +102 -9
  33. package/src/compat/protocol.ts +10 -1
  34. package/src/compat/register/actions.ts +2 -0
  35. package/src/compat/register/bundler.ts +6 -6
  36. package/src/compat/register/routing.ts +3 -4
  37. package/src/compat/tsconfig-defaults.ts +3 -2
  38. package/src/config.ts +3 -3
  39. package/src/css/postcss.ts +450 -17
  40. package/src/dev/client-actions.ts +2 -0
  41. package/src/dev/server.ts +87 -14
  42. package/src/render/island-context.ts +3 -0
  43. package/src/render/ppr.ts +14 -0
  44. package/src/render/renderer.ts +273 -43
  45. package/src/request/context.ts +32 -11
  46. package/src/resolve/scan-facts.ts +23 -4
  47. package/src/routing/proxy.ts +29 -25
  48. package/src/routing/routes.ts +1 -1
  49. package/src/runtime/loader.ts +47 -1
  50. package/src/runtime/modules.ts +546 -65
  51. package/src/runtime/vendor-build.ts +109 -22
  52. package/src/runtime/vendor.ts +7 -3
  53. package/src/utils/ansi.ts +3 -1
  54. package/src/utils/fs.ts +17 -3
@@ -2,8 +2,17 @@
2
2
  // on the CSS worker thread (./worker.ts) rather than the event loop:
3
3
  // Tailwind v4's postcss plugin is synchronous CPU work — its oxide scan and
4
4
  // compile starved every concurrent request while it ran in-process.
5
+ import { createHash } from 'node:crypto'
5
6
  import { createRequire } from 'node:module'
6
- import { existsSync, readFileSync } from 'node:fs'
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ renameSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from 'node:fs'
7
16
  import { writeFile } from 'node:fs/promises'
8
17
  import path from 'node:path'
9
18
  import { pathToFileURL } from 'node:url'
@@ -30,10 +39,64 @@ interface PostcssProcessor {
30
39
  interface LoadedPostcss {
31
40
  processor: PostcssProcessor
32
41
  sources: string
42
+ partitionedTailwindScan: boolean
33
43
  }
34
44
 
35
45
  type PostcssPluginFactory = (options?: unknown) => unknown
36
46
 
47
+ interface TailwindSource {
48
+ base: string
49
+ pattern: string
50
+ negated: boolean
51
+ }
52
+
53
+ interface TailwindScanner {
54
+ scan(): string[]
55
+ scanFiles(input: TailwindChangedContent[]): string[]
56
+ getCandidatesWithPositions(input: TailwindChangedContent): TailwindCandidatePosition[]
57
+ files: string[]
58
+ scannedFiles: string[]
59
+ globs: TailwindGlob[]
60
+ normalizedSources: TailwindGlob[]
61
+ }
62
+
63
+ interface TailwindChangedContent {
64
+ file?: string
65
+ content?: string
66
+ extension: string
67
+ }
68
+
69
+ interface TailwindCandidatePosition {
70
+ candidate: string
71
+ position: number
72
+ }
73
+
74
+ interface TailwindGlob {
75
+ base: string
76
+ pattern: string
77
+ }
78
+
79
+ type TailwindScannerConstructor = new (options: { sources?: TailwindSource[] }) => TailwindScanner
80
+
81
+ interface TailwindScanRegistration {
82
+ root: string
83
+ cacheDir: string
84
+ versions: string
85
+ }
86
+
87
+ const tailwindScannerPatch = Symbol.for('pnext.tailwind-scanner-v1')
88
+ const tailwindCandidateSchema = 1
89
+
90
+ interface TailwindOxideModule {
91
+ Scanner: TailwindScannerConstructor
92
+ [tailwindScannerPatch]?: {
93
+ registrations: TailwindScanRegistration[]
94
+ Scanner: TailwindScannerConstructor
95
+ }
96
+ }
97
+
98
+ const warnedTailwindScannerVersions = new Set<string>()
99
+
37
100
  interface PostcssConfig {
38
101
  plugins?: Record<string, unknown> | unknown[]
39
102
  }
@@ -78,10 +141,10 @@ export async function runPostcss(
78
141
  const css = await readText(cssFile)
79
142
  // The scan directives go last so they don't shift the line numbers plugins
80
143
  // report against the real source; `@source` is order-independent to Tailwind.
81
- const result = await loaded.processor.process(css + keptExclusions(loaded.sources, css, from), {
82
- from,
83
- to: cssFile,
84
- })
144
+ const result = await loaded.processor.process(
145
+ css + keptExclusions(loaded.sources, css, from, loaded.partitionedTailwindScan),
146
+ { from, to: cssFile },
147
+ )
85
148
  await writeFile(cssFile, result.css)
86
149
  }
87
150
 
@@ -91,7 +154,11 @@ async function createPostcssProcessor(root: string, options: PostcssOptions) {
91
154
  const loaded = (await import(pathToFileURL(configFile).href)) as
92
155
  PostcssConfig | { default: PostcssConfig }
93
156
  const config = 'default' in loaded && loaded.default ? loaded.default : loaded
94
- const plugins = postcssPlugins(root, config as PostcssConfig, options)
157
+ const { plugins, partitionedTailwindScan } = postcssPlugins(
158
+ root,
159
+ config as PostcssConfig,
160
+ options,
161
+ )
95
162
  if (plugins.length === 0) return undefined
96
163
  const appRequire = createRequire(path.join(root, 'package.json'))
97
164
  const postcss = interopDefault(resolvePostcss(appRequire)) as (
@@ -103,6 +170,7 @@ async function createPostcssProcessor(root: string, options: PostcssOptions) {
103
170
  return {
104
171
  processor: postcss(plugins),
105
172
  sources: tailwind ? scanExclusions(root, appRequire, options) : '',
173
+ partitionedTailwindScan,
106
174
  }
107
175
  }
108
176
 
@@ -120,23 +188,27 @@ async function createPostcssProcessor(root: string, options: PostcssOptions) {
120
188
  * stylesheet compiled with or without utilities minted out of pnext's own emitted JS.
121
189
  */
122
190
  /**
123
- * An appended `@source not <dir>` silences every explicit `@source` glob the stylesheet points
124
- * under that dir - Tailwind gives `not` precedence - so an app opting INTO scanning a package
125
- * (`@source '../../node_modules/x/dist/...'`) lost those utilities entirely. Keep only the
126
- * exclusions that no explicit `@source` in this stylesheet reaches into.
191
+ * The patched scanner partitions explicit dependency sources before applying broad exclusions, so
192
+ * it needs the complete exclusion set. The native fallback cannot do that: remove any broad
193
+ * exclusion that would otherwise override an explicit `@source` and silently drop its utilities.
127
194
  */
128
- export function keptExclusions(exclusions: string, css: string, fromFile: string): string {
129
- if (!exclusions) return exclusions
195
+ export function keptExclusions(
196
+ exclusions: string,
197
+ css: string,
198
+ fromFile: string,
199
+ partitionedTailwindScan = false,
200
+ ): string {
201
+ if (!exclusions || partitionedTailwindScan) return exclusions
130
202
  const dir = path.dirname(fromFile)
131
- const globs = [...css.matchAll(/@source\s+(?!not\s)['"]([^'"]+)['"]/g)].map(m =>
132
- path.resolve(dir, m[1]!.replace(/[*{[].*$/, '')),
203
+ const globs = [...css.matchAll(/@source\s+(?!not\s)['"]([^'"]+)['"]/g)].map(match =>
204
+ path.resolve(dir, match[1]!.replace(/[*{[].*$/, '')),
133
205
  )
134
206
  if (globs.length === 0) return exclusions
135
207
  const kept = exclusions
136
208
  .split('\n')
137
209
  .filter(line => {
138
- const m = /@source not "([^"]+)"/.exec(line)
139
- return !m || !globs.some(glob => glob === m[1] || glob.startsWith(m[1] + '/'))
210
+ const match = /@source not "([^"]+)"/.exec(line)
211
+ return !match || !globs.some(glob => glob === match[1] || glob.startsWith(match[1] + '/'))
140
212
  })
141
213
  .join('\n')
142
214
  return kept.includes('@source not') ? kept : ''
@@ -188,6 +260,7 @@ function postcssPlugins(root: string, config: PostcssConfig, options: PostcssOpt
188
260
  const entries = pluginEntries(config)
189
261
 
190
262
  const plugins: unknown[] = []
263
+ let partitionedTailwindScan = false
191
264
  for (const [specifier, pluginOptions] of entries) {
192
265
  if (pluginOptions === false) continue
193
266
  if (typeof specifier !== 'string') {
@@ -195,13 +268,373 @@ function postcssPlugins(root: string, config: PostcssConfig, options: PostcssOpt
195
268
  if (specifier) plugins.push(specifier)
196
269
  continue
197
270
  }
271
+ const patchedScanner =
272
+ specifier === '@tailwindcss/postcss'
273
+ ? configureTailwindScanner(appRequire, root, options)
274
+ : undefined
198
275
  const factory = interopDefault(appRequire(specifier) as unknown)
276
+ if (patchedScanner) {
277
+ assertTailwindScannerPatch(appRequire, patchedScanner)
278
+ partitionedTailwindScan = true
279
+ }
199
280
  const resolvedOptions = pluginOptionsFor(root, specifier, pluginOptions, options)
200
281
  plugins.push(
201
282
  typeof factory === 'function' ? (factory as PostcssPluginFactory)(resolvedOptions) : factory,
202
283
  )
203
284
  }
204
- return plugins
285
+ return { plugins, partitionedTailwindScan }
286
+ }
287
+
288
+ function configureTailwindScanner(
289
+ appRequire: NodeJS.Require,
290
+ root: string,
291
+ options: PostcssOptions,
292
+ ) {
293
+ const pluginPackage = appRequire.resolve('@tailwindcss/postcss/package.json')
294
+ const pluginRequire = createRequire(pluginPackage)
295
+ const oxide = pluginRequire('@tailwindcss/oxide') as TailwindOxideModule
296
+ const versions = ['@tailwindcss/postcss', 'tailwindcss', '@tailwindcss/oxide'].map(name => ({
297
+ name,
298
+ version: packageVersionText(pluginRequire, name),
299
+ tuple: packageVersion(pluginRequire, name),
300
+ }))
301
+ // The monkey-patch and source partitioning contract are covered against Tailwind 4.3.x.
302
+ if (!versions.every(({ tuple: [major, minor] }) => major === 4 && minor === 3)) {
303
+ const label = versions.map(({ name, version }) => `${name}@${version}`).join(', ')
304
+ if (!warnedTailwindScannerVersions.has(label)) {
305
+ warnedTailwindScannerVersions.add(label)
306
+ console.warn(`PNext: unsupported Tailwind scanner versions (${label}); using native Scanner.`)
307
+ }
308
+ return undefined
309
+ }
310
+ const registration = {
311
+ root: path.resolve(root),
312
+ cacheDir: path.join(
313
+ path.resolve(root, options.outDir ?? '.pnext'),
314
+ 'cache/tailwind/candidates/v1',
315
+ ),
316
+ versions: ['@tailwindcss/postcss', 'tailwindcss', '@tailwindcss/oxide', 'postcss']
317
+ .map(name => `${name}@${packageVersionText(pluginRequire, name)}`)
318
+ .join('\0'),
319
+ }
320
+ const installed = oxide[tailwindScannerPatch]
321
+ if (installed) {
322
+ if (oxide.Scanner !== installed.Scanner) throw tailwindScannerPatchError()
323
+ const index = installed.registrations.findIndex(item => item.root === registration.root)
324
+ if (index === -1) installed.registrations.push(registration)
325
+ else installed.registrations[index] = registration
326
+ return installed.Scanner
327
+ }
328
+
329
+ const NativeScanner = oxide.Scanner
330
+ const registrations = [registration]
331
+ class PnextTailwindScanner implements TailwindScanner {
332
+ private readonly scanners: {
333
+ scanner: TailwindScanner
334
+ inventory: TailwindScanner
335
+ sources: TailwindSource[]
336
+ }[]
337
+ private readonly registration?: TailwindScanRegistration
338
+
339
+ constructor({ sources = [] }: { sources?: TailwindSource[] }) {
340
+ this.registration = tailwindRegistration(registrations, sources)
341
+ this.scanners = partitionTailwindSources(sources).map(partition => ({
342
+ scanner: new NativeScanner({ sources: partition }),
343
+ inventory: new NativeScanner({ sources: partition }),
344
+ sources: partition,
345
+ }))
346
+ }
347
+
348
+ scan() {
349
+ return sortedUnique(
350
+ this.scanners.flatMap(({ scanner, inventory, sources }) =>
351
+ this.registration
352
+ ? cachedTailwindCandidates(inventory, sources, this.registration, () =>
353
+ new NativeScanner({ sources }).scan(),
354
+ )
355
+ : scanner.scan(),
356
+ ),
357
+ )
358
+ }
359
+
360
+ scanFiles(input: TailwindChangedContent[]) {
361
+ return sortedUnique(this.scanners.flatMap(({ scanner }) => scanner.scanFiles(input)))
362
+ }
363
+
364
+ getCandidatesWithPositions(input: TailwindChangedContent) {
365
+ return this.scanners.flatMap(({ scanner }) => scanner.getCandidatesWithPositions(input))
366
+ }
367
+
368
+ get files() {
369
+ return sortedUnique(this.scanners.flatMap(({ inventory }) => inventory.files))
370
+ }
371
+
372
+ get scannedFiles() {
373
+ return sortedUnique(this.scanners.flatMap(({ scanner }) => scanner.scannedFiles))
374
+ }
375
+
376
+ get globs() {
377
+ return uniqueTailwindGlobs(this.scanners.flatMap(({ scanner }) => scanner.globs))
378
+ }
379
+
380
+ get normalizedSources() {
381
+ return uniqueTailwindGlobs(this.scanners.flatMap(({ scanner }) => scanner.normalizedSources))
382
+ }
383
+ }
384
+
385
+ oxide.Scanner = PnextTailwindScanner
386
+ oxide[tailwindScannerPatch] = { registrations, Scanner: PnextTailwindScanner }
387
+ return PnextTailwindScanner
388
+ }
389
+
390
+ function assertTailwindScannerPatch(
391
+ appRequire: NodeJS.Require,
392
+ expected: TailwindScannerConstructor,
393
+ ) {
394
+ const pluginPackage = appRequire.resolve('@tailwindcss/postcss/package.json')
395
+ const oxide = createRequire(pluginPackage)('@tailwindcss/oxide') as TailwindOxideModule
396
+ if (oxide.Scanner !== expected || oxide[tailwindScannerPatch]?.Scanner !== expected) {
397
+ throw tailwindScannerPatchError()
398
+ }
399
+ }
400
+
401
+ function tailwindScannerPatchError() {
402
+ return new Error(
403
+ 'PNext could not install its Tailwind Scanner integration. The loaded @tailwindcss/postcss module does not observe the patched @tailwindcss/oxide Scanner; refusing to drop explicit @source utilities.',
404
+ )
405
+ }
406
+
407
+ function partitionTailwindSources(sources: TailwindSource[]): TailwindSource[][] {
408
+ const broadExclusions = sources.filter(source => source.negated && !/[*?{[]/.test(source.pattern))
409
+ const dependencySources = sources.filter(
410
+ source =>
411
+ !source.negated &&
412
+ broadExclusions.some(exclusion => isInside(sourcePrefix(exclusion), sourcePrefix(source))),
413
+ )
414
+ if (dependencySources.length === 0) return [sources]
415
+ const dependencySet = new Set(dependencySources)
416
+ const appSources = sources.filter(source => !dependencySet.has(source))
417
+ return [appSources].concat(
418
+ dependencySources.map(source =>
419
+ [
420
+ source,
421
+ ...sources.filter(
422
+ exclusion =>
423
+ exclusion.negated &&
424
+ !broadExclusions.includes(exclusion) &&
425
+ pathsOverlap(sourcePrefix(source), sourcePrefix(exclusion)),
426
+ ),
427
+ ].map(resolveTailwindSource),
428
+ ),
429
+ )
430
+ }
431
+
432
+ function resolveTailwindSource(source: TailwindSource): TailwindSource {
433
+ const globAt = source.pattern.search(/[*?{[]/)
434
+ const literal = (globAt === -1 ? source.pattern : source.pattern.slice(0, globAt)).replace(
435
+ /[\\/]$/,
436
+ '',
437
+ )
438
+ const absolutePattern = path.resolve(source.base, source.pattern)
439
+ let absolute = path.resolve(source.base, literal || '.')
440
+ while (!existsSync(absolute)) {
441
+ const parent = path.dirname(absolute)
442
+ if (parent === absolute) return source
443
+ absolute = parent
444
+ }
445
+ try {
446
+ const stat = statSync(absolute)
447
+ if (globAt === -1 && stat.isFile()) {
448
+ return {
449
+ ...source,
450
+ base: realpathSync.native(path.dirname(absolute)),
451
+ pattern: path.basename(absolute),
452
+ }
453
+ }
454
+ return {
455
+ ...source,
456
+ base: realpathSync.native(absolute),
457
+ pattern:
458
+ globAt === -1 ? '**/*' : path.relative(absolute, absolutePattern).split(path.sep).join('/'),
459
+ }
460
+ } catch {
461
+ return source
462
+ }
463
+ }
464
+
465
+ function sourcePrefix(source: TailwindSource) {
466
+ const prefix = source.pattern.replace(/[*?{[].*$/, '').replace(/[\\/]$/, '') || '.'
467
+ return path.resolve(source.base, prefix)
468
+ }
469
+
470
+ function pathsOverlap(left: string, right: string) {
471
+ return isInside(left, right) || isInside(right, left)
472
+ }
473
+
474
+ function isInside(parent: string, child: string) {
475
+ const relative = path.relative(parent, child)
476
+ return (
477
+ relative === '' ||
478
+ (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
479
+ )
480
+ }
481
+
482
+ function tailwindRegistration(
483
+ registrations: TailwindScanRegistration[],
484
+ sources: TailwindSource[],
485
+ ) {
486
+ return [...registrations]
487
+ .sort((left, right) => right.root.length - left.root.length)
488
+ .find(item => sources.some(source => isInside(item.root, path.resolve(source.base))))
489
+ }
490
+
491
+ function cachedTailwindCandidates(
492
+ inventory: TailwindScanner,
493
+ sources: TailwindSource[],
494
+ registration: TailwindScanRegistration,
495
+ scan: () => string[],
496
+ ) {
497
+ let cache: { file: string; fingerprint: string; files: number }
498
+ try {
499
+ cache = tailwindCacheIdentity(inventory, sources, registration)
500
+ } catch {
501
+ return scan()
502
+ }
503
+ const stored = readJson<{
504
+ schema?: number
505
+ fingerprint?: string
506
+ candidates?: unknown
507
+ }>(cache.file)
508
+ if (
509
+ stored?.schema === tailwindCandidateSchema &&
510
+ stored.fingerprint === cache.fingerprint &&
511
+ Array.isArray(stored.candidates) &&
512
+ stored.candidates.every(candidate => typeof candidate === 'string')
513
+ ) {
514
+ return stored.candidates
515
+ }
516
+
517
+ const candidates = scan()
518
+ try {
519
+ mkdirSync(registration.cacheDir, { recursive: true })
520
+ atomicJsonWrite(cache.file, {
521
+ schema: tailwindCandidateSchema,
522
+ fingerprint: cache.fingerprint,
523
+ candidates,
524
+ files: cache.files,
525
+ })
526
+ } catch {
527
+ // Cache writes are optional; the fresh Oxide result remains correct.
528
+ }
529
+ return candidates
530
+ }
531
+
532
+ function tailwindCacheIdentity(
533
+ scanner: TailwindScanner,
534
+ sources: TailwindSource[],
535
+ registration: TailwindScanRegistration,
536
+ ) {
537
+ const identities = sources.map(tailwindSourceIdentity)
538
+ const sourceKey = createHash('sha256')
539
+ .update(String(tailwindCandidateSchema))
540
+ .update('\0')
541
+ .update(registration.versions)
542
+ .update('\0')
543
+ .update(JSON.stringify(identities))
544
+ .digest('hex')
545
+ const files = scanner.files.map(file => path.resolve(file)).sort()
546
+ const fingerprint = createHash('sha256').update(sourceKey)
547
+ for (const file of files) {
548
+ fingerprint.update('\0').update(file).update('\0').update(readFileSync(file))
549
+ }
550
+ return {
551
+ file: path.join(registration.cacheDir, `${sourceKey}.json`),
552
+ fingerprint: fingerprint.digest('hex'),
553
+ files: files.length,
554
+ }
555
+ }
556
+
557
+ function tailwindSourceIdentity(source: TailwindSource) {
558
+ const prefix = sourcePrefix(source)
559
+ const real = realExistingPath(prefix)
560
+ const packageFile = nearestPackageJson(real)
561
+ let packageIdentity: { name?: string; version?: string } | undefined
562
+ if (packageFile) {
563
+ const pkg = readJson<{ name?: string; version?: string }>(packageFile)
564
+ if (pkg) {
565
+ packageIdentity = { name: pkg.name, version: pkg.version }
566
+ }
567
+ }
568
+ return { ...source, base: path.resolve(source.base), real, package: packageIdentity }
569
+ }
570
+
571
+ function realExistingPath(input: string) {
572
+ let current = input
573
+ while (!existsSync(current)) {
574
+ const parent = path.dirname(current)
575
+ if (parent === current) return input
576
+ current = parent
577
+ }
578
+ try {
579
+ const stat = statSync(current)
580
+ return realpathSync.native(stat.isFile() ? path.dirname(current) : current)
581
+ } catch {
582
+ return current
583
+ }
584
+ }
585
+
586
+ function nearestPackageJson(input: string) {
587
+ let current = input
588
+ while (true) {
589
+ const file = path.join(current, 'package.json')
590
+ if (existsSync(file)) return file
591
+ const parent = path.dirname(current)
592
+ if (parent === current) break
593
+ current = parent
594
+ }
595
+ return undefined
596
+ }
597
+
598
+ function atomicJsonWrite(file: string, value: unknown) {
599
+ const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
600
+ writeFileSync(temporary, JSON.stringify(value))
601
+ renameSync(temporary, file)
602
+ }
603
+
604
+ function readJson<T>(file: string): T | undefined {
605
+ try {
606
+ return JSON.parse(readFileSync(file, 'utf8')) as T
607
+ } catch {
608
+ return undefined
609
+ }
610
+ }
611
+
612
+ function sortedUnique(values: string[]) {
613
+ return [...new Set(values)].sort()
614
+ }
615
+
616
+ function uniqueTailwindGlobs(globs: TailwindGlob[]) {
617
+ const seen = new Set<string>()
618
+ return globs.filter(glob => {
619
+ const key = `${glob.base}\0${glob.pattern}`
620
+ if (seen.has(key)) return false
621
+ seen.add(key)
622
+ return true
623
+ })
624
+ }
625
+
626
+ function packageVersionText(req: NodeJS.Require, name: string) {
627
+ try {
628
+ return (
629
+ (
630
+ JSON.parse(readFileSync(req.resolve(`${name}/package.json`), 'utf8')) as {
631
+ version?: string
632
+ }
633
+ ).version ?? 'unknown'
634
+ )
635
+ } catch {
636
+ return 'unknown'
637
+ }
205
638
  }
206
639
 
207
640
  function pluginOptionsFor(
@@ -20,6 +20,8 @@ export interface ClientActionBundler {
20
20
  isClientActionModule: (file: string) => boolean
21
21
  /** Generate the client fetch-stub source for an action module file. */
22
22
  stubSource: (file: string) => Promise<string>
23
+ /** Read a client-layer source and replace an undiscovered action module with its RPC stub. */
24
+ loadClientSource: (file: string, root: string) => Promise<{ source: string; stubSource?: string }>
23
25
  }
24
26
 
25
27
  let bundler: ClientActionBundler | undefined