@webstir-io/webstir-frontend 0.1.40 → 0.1.41

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 (138) hide show
  1. package/README.md +124 -60
  2. package/dist/assets/imageOptimizer.js +10 -15
  3. package/dist/assets/precompression.js +1 -1
  4. package/dist/builders/contentBuilder.js +102 -90
  5. package/dist/builders/cssBuilder.js +25 -19
  6. package/dist/builders/htmlBuilder.js +57 -42
  7. package/dist/builders/index.js +1 -1
  8. package/dist/builders/jsBuilder.js +219 -76
  9. package/dist/builders/staticAssetsBuilder.js +27 -9
  10. package/dist/builders/types.d.ts +1 -0
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +6 -30
  13. package/dist/config/manifest.js +7 -6
  14. package/dist/config/paths.js +2 -2
  15. package/dist/config/schema.d.ts +8 -0
  16. package/dist/config/schema.js +7 -6
  17. package/dist/config/setup.js +1 -1
  18. package/dist/config/workspace.js +11 -9
  19. package/dist/core/constants.d.ts +1 -1
  20. package/dist/core/constants.js +5 -5
  21. package/dist/core/diagnostics.js +1 -1
  22. package/dist/core/pages.js +4 -4
  23. package/dist/hooks.js +3 -3
  24. package/dist/html/criticalCss.js +6 -3
  25. package/dist/html/htmlSecurity.d.ts +6 -1
  26. package/dist/html/htmlSecurity.js +28 -14
  27. package/dist/html/lazyLoad.js +1 -1
  28. package/dist/html/pageScaffold.js +1 -1
  29. package/dist/html/resourceHints.js +5 -2
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +2 -0
  32. package/dist/inspect.d.ts +2 -0
  33. package/dist/inspect.js +110 -0
  34. package/dist/modes/ssg/metadata.js +4 -4
  35. package/dist/modes/ssg/routing.js +2 -5
  36. package/dist/modes/ssg/seo.js +5 -5
  37. package/dist/modes/ssg/views.js +17 -11
  38. package/dist/operations.js +18 -10
  39. package/dist/pipeline.d.ts +1 -0
  40. package/dist/pipeline.js +6 -1
  41. package/dist/provider.js +28 -24
  42. package/dist/runtime/boundary.d.ts +28 -0
  43. package/dist/runtime/boundary.js +247 -0
  44. package/dist/runtime/index.d.ts +1 -0
  45. package/dist/runtime/index.js +1 -0
  46. package/dist/types.d.ts +52 -0
  47. package/dist/utils/fs.d.ts +11 -10
  48. package/dist/utils/fs.js +48 -20
  49. package/dist/utils/glob.d.ts +8 -0
  50. package/dist/utils/glob.js +21 -0
  51. package/dist/utils/hash.js +1 -2
  52. package/dist/utils/pagePaths.js +2 -2
  53. package/package.json +19 -14
  54. package/scripts/publish.sh +2 -94
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/assets/assetManifest.ts +39 -29
  57. package/src/assets/imageOptimizer.ts +91 -82
  58. package/src/assets/precompression.ts +22 -16
  59. package/src/builders/contentBuilder.ts +1224 -1149
  60. package/src/builders/cssBuilder.ts +466 -417
  61. package/src/builders/htmlBuilder.ts +511 -448
  62. package/src/builders/index.ts +7 -7
  63. package/src/builders/jsBuilder.ts +538 -280
  64. package/src/builders/staticAssetsBuilder.ts +166 -135
  65. package/src/builders/types.ts +7 -6
  66. package/src/cli.ts +66 -90
  67. package/src/config/manifest.ts +16 -14
  68. package/src/config/paths.ts +5 -5
  69. package/src/config/schema.ts +38 -37
  70. package/src/config/setup.ts +7 -7
  71. package/src/config/workspace.ts +118 -116
  72. package/src/config/workspaceManifest.ts +14 -14
  73. package/src/core/constants.ts +62 -62
  74. package/src/core/diagnostics.ts +26 -26
  75. package/src/core/pages.ts +19 -19
  76. package/src/hooks.ts +128 -118
  77. package/src/html/criticalCss.ts +84 -77
  78. package/src/html/htmlSecurity.ts +107 -66
  79. package/src/html/lazyLoad.ts +22 -19
  80. package/src/html/pageScaffold.ts +37 -28
  81. package/src/html/resourceHints.ts +83 -74
  82. package/src/index.ts +2 -0
  83. package/src/inspect.ts +158 -0
  84. package/src/modes/ssg/metadata.ts +53 -51
  85. package/src/modes/ssg/routing.ts +177 -177
  86. package/src/modes/ssg/seo.ts +208 -200
  87. package/src/modes/ssg/validation.ts +31 -25
  88. package/src/modes/ssg/views.ts +257 -238
  89. package/src/operations.ts +105 -95
  90. package/src/pipeline.ts +81 -69
  91. package/src/provider.ts +184 -176
  92. package/src/runtime/boundary.ts +325 -0
  93. package/src/runtime/index.ts +1 -0
  94. package/src/types.ts +107 -48
  95. package/src/utils/changedFile.ts +22 -22
  96. package/src/utils/fs.ts +73 -26
  97. package/src/utils/glob.ts +38 -0
  98. package/src/utils/hash.ts +2 -4
  99. package/src/utils/pagePaths.ts +35 -23
  100. package/src/utils/pathMatch.ts +26 -23
  101. package/tests/add-page-defaults.test.js +44 -39
  102. package/tests/bundlerParity.test.js +252 -0
  103. package/tests/cli.contract.test.js +13 -0
  104. package/tests/content-pages.test.js +108 -13
  105. package/tests/css-app-imports.test.js +22 -11
  106. package/tests/css-page-imports.test.js +26 -13
  107. package/tests/diagnostics.test.js +39 -36
  108. package/tests/features.test.js +48 -43
  109. package/tests/hooks.test.js +58 -42
  110. package/tests/htmlSecurity.test.js +66 -0
  111. package/tests/inspect.test.js +148 -0
  112. package/tests/provider.integration.test.js +71 -20
  113. package/tests/runtime.test.js +493 -0
  114. package/tests/ssg-defaults.test.js +284 -177
  115. package/tests/ssg-guardrails.test.js +51 -51
  116. package/tsconfig.json +3 -10
  117. package/dist/watch/frontendFiles.d.ts +0 -3
  118. package/dist/watch/frontendFiles.js +0 -25
  119. package/dist/watch/hotUpdateTracker.d.ts +0 -51
  120. package/dist/watch/hotUpdateTracker.js +0 -205
  121. package/dist/watch/pipelineHelpers.d.ts +0 -26
  122. package/dist/watch/pipelineHelpers.js +0 -177
  123. package/dist/watch/types.d.ts +0 -27
  124. package/dist/watch/types.js +0 -1
  125. package/dist/watch/watchCoordinator.d.ts +0 -36
  126. package/dist/watch/watchCoordinator.js +0 -551
  127. package/dist/watch/watchDaemon.d.ts +0 -17
  128. package/dist/watch/watchDaemon.js +0 -127
  129. package/dist/watch/watchReporter.d.ts +0 -21
  130. package/dist/watch/watchReporter.js +0 -64
  131. package/scripts/smoke.mjs +0 -35
  132. package/src/watch/frontendFiles.ts +0 -32
  133. package/src/watch/hotUpdateTracker.ts +0 -285
  134. package/src/watch/pipelineHelpers.ts +0 -242
  135. package/src/watch/types.ts +0 -23
  136. package/src/watch/watchCoordinator.ts +0 -666
  137. package/src/watch/watchDaemon.ts +0 -144
  138. package/src/watch/watchReporter.ts +0 -98
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { stat } from './fs.js';
3
+
4
+ export interface GlobScanOptions {
5
+ readonly cwd: string;
6
+ readonly absolute?: boolean;
7
+ readonly dot?: boolean;
8
+ readonly onlyFiles?: boolean;
9
+ }
10
+
11
+ export async function scanGlob(pattern: string, options: GlobScanOptions): Promise<string[]> {
12
+ const glob = new Bun.Glob(pattern);
13
+ const matches = await Array.fromAsync(glob.scan(options));
14
+ matches.sort((a, b) => a.localeCompare(b));
15
+ return matches;
16
+ }
17
+
18
+ export async function scanDirectories(
19
+ pattern: string,
20
+ options: Omit<GlobScanOptions, 'onlyFiles'>,
21
+ ): Promise<string[]> {
22
+ const matches = await scanGlob(pattern, { ...options, onlyFiles: false });
23
+ const directories = await Promise.all(
24
+ matches.map(async (match) => {
25
+ const absolutePath =
26
+ options.absolute || path.isAbsolute(match) ? match : path.join(options.cwd, match);
27
+ const info = await stat(absolutePath).catch(() => null);
28
+ if (!info?.isDirectory()) {
29
+ return null;
30
+ }
31
+
32
+ const normalized = match.replace(/[\\/]+$/, '');
33
+ return normalized.length > 0 ? normalized : null;
34
+ }),
35
+ );
36
+
37
+ return directories.filter((value): value is string => value !== null);
38
+ }
package/src/utils/hash.ts CHANGED
@@ -1,6 +1,4 @@
1
- import { createHash } from 'node:crypto';
2
-
3
1
  export function hashContent(content: string, length = 8): string {
4
- const hash = createHash('sha256').update(content).digest('hex');
5
- return hash.slice(0, length);
2
+ const hash = new Bun.CryptoHasher('sha256').update(content).digest('hex');
3
+ return hash.slice(0, length);
6
4
  }
@@ -2,42 +2,54 @@ import path from 'node:path';
2
2
  import { FOLDERS, FILES } from '../core/constants.js';
3
3
 
4
4
  export function resolvePagesUrlPrefix(frontendRoot: string, pagesRoot: string): string {
5
- const relative = path.relative(frontendRoot, pagesRoot).replace(/\\/g, '/');
6
- if (!relative || relative === '.' || relative.startsWith('..')) {
7
- return '';
8
- }
9
- return `/${trimSlashes(relative)}`;
5
+ const relative = path.relative(frontendRoot, pagesRoot).replace(/\\/g, '/');
6
+ if (!relative || relative === '.' || relative.startsWith('..')) {
7
+ return '';
8
+ }
9
+ return `/${trimSlashes(relative)}`;
10
10
  }
11
11
 
12
12
  export function isRootPagesLayout(frontendRoot: string, pagesRoot: string): boolean {
13
- return resolvePagesUrlPrefix(frontendRoot, pagesRoot) === '';
13
+ return resolvePagesUrlPrefix(frontendRoot, pagesRoot) === '';
14
14
  }
15
15
 
16
- export function resolvePageAssetUrl(pagesUrlPrefix: string, pageName: string, fileName: string): string {
17
- return joinUrl(pagesUrlPrefix, pageName, fileName);
16
+ export function resolvePageAssetUrl(
17
+ pagesUrlPrefix: string,
18
+ pageName: string,
19
+ fileName: string,
20
+ ): string {
21
+ return joinUrl(pagesUrlPrefix, pageName, fileName);
18
22
  }
19
23
 
20
- export function resolvePageHtmlUrl(pagesUrlPrefix: string, pageName: string, useRootIndex: boolean): string {
21
- if (useRootIndex && pageName === FOLDERS.home) {
22
- return `/${FILES.indexHtml}`;
23
- }
24
- return joinUrl(pagesUrlPrefix, pageName, FILES.indexHtml);
24
+ export function resolvePageHtmlUrl(
25
+ pagesUrlPrefix: string,
26
+ pageName: string,
27
+ useRootIndex: boolean,
28
+ ): string {
29
+ if (useRootIndex && pageName === FOLDERS.home) {
30
+ return `/${FILES.indexHtml}`;
31
+ }
32
+ return joinUrl(pagesUrlPrefix, pageName, FILES.indexHtml);
25
33
  }
26
34
 
27
- export function resolvePageHtmlDir(pagesRoot: string, pageName: string, useRootIndex: boolean): string {
28
- if (useRootIndex && pageName === FOLDERS.home) {
29
- return pagesRoot;
30
- }
31
- return path.join(pagesRoot, pageName);
35
+ export function resolvePageHtmlDir(
36
+ pagesRoot: string,
37
+ pageName: string,
38
+ useRootIndex: boolean,
39
+ ): string {
40
+ if (useRootIndex && pageName === FOLDERS.home) {
41
+ return pagesRoot;
42
+ }
43
+ return path.join(pagesRoot, pageName);
32
44
  }
33
45
 
34
46
  function joinUrl(...segments: string[]): string {
35
- const cleaned = segments
36
- .map(segment => trimSlashes(segment))
37
- .filter(segment => segment.length > 0);
38
- return `/${cleaned.join('/')}`;
47
+ const cleaned = segments
48
+ .map((segment) => trimSlashes(segment))
49
+ .filter((segment) => segment.length > 0);
50
+ return `/${cleaned.join('/')}`;
39
51
  }
40
52
 
41
53
  function trimSlashes(value: string): string {
42
- return value.replace(/^\/+|\/+$/g, '');
54
+ return value.replace(/^\/+|\/+$/g, '');
43
55
  }
@@ -1,36 +1,39 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  export function isInsideDirectory(filePath: string, directory: string): boolean {
4
- const resolvedFile = path.resolve(filePath);
5
- const resolvedDirectory = path.resolve(directory);
6
- const relative = path.relative(resolvedDirectory, resolvedFile);
7
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
4
+ const resolvedFile = path.resolve(filePath);
5
+ const resolvedDirectory = path.resolve(directory);
6
+ const relative = path.relative(resolvedDirectory, resolvedFile);
7
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
8
8
  }
9
9
 
10
- export function findPageFromChangedFile(changedFile: string | undefined, pagesRoot: string): string | null {
11
- if (!changedFile) {
12
- return null;
13
- }
10
+ export function findPageFromChangedFile(
11
+ changedFile: string | undefined,
12
+ pagesRoot: string,
13
+ ): string | null {
14
+ if (!changedFile) {
15
+ return null;
16
+ }
14
17
 
15
- const resolvedChanged = path.resolve(changedFile);
16
- const resolvedPagesRoot = path.resolve(pagesRoot);
17
- if (!isInsideDirectory(resolvedChanged, resolvedPagesRoot)) {
18
- return null;
19
- }
18
+ const resolvedChanged = path.resolve(changedFile);
19
+ const resolvedPagesRoot = path.resolve(pagesRoot);
20
+ if (!isInsideDirectory(resolvedChanged, resolvedPagesRoot)) {
21
+ return null;
22
+ }
20
23
 
21
- const relative = path.relative(resolvedPagesRoot, resolvedChanged);
22
- const segments = relative.split(path.sep);
23
- return segments.length > 0 && segments[0] ? segments[0] : null;
24
+ const relative = path.relative(resolvedPagesRoot, resolvedChanged);
25
+ const segments = relative.split(path.sep);
26
+ return segments.length > 0 && segments[0] ? segments[0] : null;
24
27
  }
25
28
 
26
29
  export function relativePathWithin(filePath: string | undefined, directory: string): string | null {
27
- if (!filePath) {
28
- return null;
29
- }
30
+ if (!filePath) {
31
+ return null;
32
+ }
30
33
 
31
- if (!isInsideDirectory(filePath, directory)) {
32
- return null;
33
- }
34
+ if (!isInsideDirectory(filePath, directory)) {
35
+ return null;
36
+ }
34
37
 
35
- return path.relative(path.resolve(directory), path.resolve(filePath));
38
+ return path.relative(path.resolve(directory), path.resolve(filePath));
36
39
  }
@@ -8,57 +8,62 @@ import path from 'node:path';
8
8
  import { runAddPage } from '../dist/index.js';
9
9
 
10
10
  async function createWorkspace(pkg) {
11
- const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-frontend-add-page-'));
12
- await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(pkg, null, 2), 'utf8');
13
- return root;
11
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-frontend-add-page-'));
12
+ await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(pkg, null, 2), 'utf8');
13
+ return root;
14
14
  }
15
15
 
16
16
  test('add-page defaults to ssg scaffold when webstir.mode=ssg', async () => {
17
- const workspace = await createWorkspace({
18
- name: 'webstir-project',
19
- version: '1.0.0',
20
- webstir: { mode: 'ssg' }
21
- });
17
+ const workspace = await createWorkspace({
18
+ name: 'webstir-project',
19
+ version: '1.0.0',
20
+ webstir: { mode: 'ssg' },
21
+ });
22
22
 
23
- try {
24
- await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
23
+ try {
24
+ await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
25
25
 
26
- const pageDir = path.join(workspace, 'src', 'frontend', 'pages', 'about');
27
- const htmlPath = path.join(pageDir, 'index.html');
28
- const cssPath = path.join(pageDir, 'index.css');
29
- const tsPath = path.join(pageDir, 'index.ts');
26
+ const pageDir = path.join(workspace, 'src', 'frontend', 'pages', 'about');
27
+ const htmlPath = path.join(pageDir, 'index.html');
28
+ const cssPath = path.join(pageDir, 'index.css');
29
+ const tsPath = path.join(pageDir, 'index.ts');
30
30
 
31
- assert.equal(fssync.existsSync(htmlPath), true);
32
- assert.equal(fssync.existsSync(cssPath), true);
33
- assert.equal(fssync.existsSync(tsPath), false);
31
+ assert.equal(fssync.existsSync(htmlPath), true);
32
+ assert.equal(fssync.existsSync(cssPath), true);
33
+ assert.equal(fssync.existsSync(tsPath), false);
34
34
 
35
- const html = await fs.readFile(htmlPath, 'utf8');
36
- assert.ok(!html.includes('<script type="module"'), 'ssg scaffold should not include module script tag');
37
- } finally {
38
- await fs.rm(workspace, { recursive: true, force: true });
39
- }
35
+ const html = await fs.readFile(htmlPath, 'utf8');
36
+ assert.ok(
37
+ !html.includes('<script type="module"'),
38
+ 'ssg scaffold should not include module script tag',
39
+ );
40
+ } finally {
41
+ await fs.rm(workspace, { recursive: true, force: true });
42
+ }
40
43
  });
41
44
 
42
45
  test('add-page defaults to standard scaffold when webstir.mode is not ssg', async () => {
43
- const workspace = await createWorkspace({
44
- name: 'webstir-project',
45
- version: '1.0.0'
46
- });
46
+ const workspace = await createWorkspace({
47
+ name: 'webstir-project',
48
+ version: '1.0.0',
49
+ });
47
50
 
48
- try {
49
- await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
51
+ try {
52
+ await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
50
53
 
51
- const pageDir = path.join(workspace, 'src', 'frontend', 'pages', 'about');
52
- const htmlPath = path.join(pageDir, 'index.html');
53
- const tsPath = path.join(pageDir, 'index.ts');
54
+ const pageDir = path.join(workspace, 'src', 'frontend', 'pages', 'about');
55
+ const htmlPath = path.join(pageDir, 'index.html');
56
+ const tsPath = path.join(pageDir, 'index.ts');
54
57
 
55
- assert.equal(fssync.existsSync(htmlPath), true);
56
- assert.equal(fssync.existsSync(tsPath), true);
58
+ assert.equal(fssync.existsSync(htmlPath), true);
59
+ assert.equal(fssync.existsSync(tsPath), true);
57
60
 
58
- const html = await fs.readFile(htmlPath, 'utf8');
59
- assert.ok(html.includes('<script type="module"'), 'standard scaffold should include module script tag');
60
- } finally {
61
- await fs.rm(workspace, { recursive: true, force: true });
62
- }
61
+ const html = await fs.readFile(htmlPath, 'utf8');
62
+ assert.ok(
63
+ html.includes('<script type="module"'),
64
+ 'standard scaffold should include module script tag',
65
+ );
66
+ } finally {
67
+ await fs.rm(workspace, { recursive: true, force: true });
68
+ }
63
69
  });
64
-
@@ -0,0 +1,252 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ import { frontendProvider } from '../dist/index.js';
10
+
11
+ async function createWorkspace(prefix = 'webstir-frontend-bundler-parity-') {
12
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
13
+ const appDir = path.join(root, 'src', 'frontend', 'app');
14
+ const appScriptsDir = path.join(appDir, 'scripts');
15
+ const pageDir = path.join(root, 'src', 'frontend', 'pages', 'home');
16
+
17
+ await fs.mkdir(appScriptsDir, { recursive: true });
18
+ await fs.mkdir(pageDir, { recursive: true });
19
+
20
+ await fs.writeFile(
21
+ path.join(appDir, 'app.html'),
22
+ '<!DOCTYPE html><html><head><title>App</title></head><body><main></main><script type="module" src="/app/app.js"></script></body></html>',
23
+ 'utf8',
24
+ );
25
+ await fs.writeFile(path.join(appScriptsDir, 'boot.ts'), 'export const boot = "ready";\n', 'utf8');
26
+ await fs.writeFile(
27
+ path.join(appDir, 'app.ts'),
28
+ 'import { boot } from "./scripts/boot"; console.log(boot);\n',
29
+ 'utf8',
30
+ );
31
+ await fs.writeFile(
32
+ path.join(pageDir, 'index.html'),
33
+ '<head></head><main><section>Home</section></main>',
34
+ 'utf8',
35
+ );
36
+ await fs.writeFile(path.join(pageDir, 'message.ts'), 'export const message = "home";\n', 'utf8');
37
+ await fs.writeFile(
38
+ path.join(pageDir, 'index.ts'),
39
+ 'import { message } from "./message"; console.log(message);\n',
40
+ 'utf8',
41
+ );
42
+
43
+ return root;
44
+ }
45
+
46
+ function getPackageRoot() {
47
+ const here = path.dirname(fileURLToPath(import.meta.url));
48
+ return path.resolve(here, '..');
49
+ }
50
+
51
+ async function listRelativeFiles(root) {
52
+ const collected = [];
53
+
54
+ async function walk(current, prefix = '') {
55
+ let entries = [];
56
+ try {
57
+ entries = await fs.readdir(current, { withFileTypes: true });
58
+ } catch (error) {
59
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
60
+ return;
61
+ }
62
+ throw error;
63
+ }
64
+
65
+ entries.sort((a, b) => a.name.localeCompare(b.name));
66
+ for (const entry of entries) {
67
+ const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name;
68
+ const absolutePath = path.join(current, entry.name);
69
+ if (entry.isDirectory()) {
70
+ await walk(absolutePath, relativePath);
71
+ continue;
72
+ }
73
+ collected.push(relativePath);
74
+ }
75
+ }
76
+
77
+ await walk(root);
78
+ return collected;
79
+ }
80
+
81
+ async function readJsonOrNull(filePath) {
82
+ try {
83
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
84
+ } catch (error) {
85
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
86
+ return null;
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ async function snapshotWorkspace(workspace, entryPoints) {
93
+ const buildRoot = path.join(workspace, 'build', 'frontend');
94
+ const distRoot = path.join(workspace, 'dist', 'frontend');
95
+ const sharedManifest = await readJsonOrNull(path.join(distRoot, 'manifest.json'));
96
+ const pageManifest = await readJsonOrNull(path.join(distRoot, 'pages', 'home', 'manifest.json'));
97
+
98
+ const sharedJs = sharedManifest?.shared?.js;
99
+ const pageJs = pageManifest?.pages?.home?.js;
100
+
101
+ assert.match(sharedJs ?? '', /^app-[a-z0-9]+\.js$/i, 'expected hashed shared app bundle');
102
+ assert.match(pageJs ?? '', /^index-[a-z0-9]+\.js$/i, 'expected hashed page bundle');
103
+ await fs.access(path.join(distRoot, 'app', sharedJs));
104
+ await fs.access(path.join(distRoot, 'pages', 'home', pageJs));
105
+
106
+ return {
107
+ entryPoints: [...entryPoints].sort(),
108
+ buildFiles: await listRelativeFiles(buildRoot),
109
+ distFiles: (await listRelativeFiles(distRoot)).map(normalizeHashedPath),
110
+ sharedJs: normalizeHashedName(sharedJs),
111
+ pageJs: normalizeHashedName(pageJs),
112
+ };
113
+ }
114
+
115
+ function normalizeHashedPath(relativePath) {
116
+ return relativePath
117
+ .replace(/app-[a-z0-9]+\.js$/i, 'app-[hash].js')
118
+ .replace(/app-[a-z0-9]+\.js\.(br|gz)$/i, 'app-[hash].js.$1')
119
+ .replace(/index-[a-z0-9]+\.js$/i, 'index-[hash].js')
120
+ .replace(/index-[a-z0-9]+\.js\.(br|gz)$/i, 'index-[hash].js.$1');
121
+ }
122
+
123
+ function normalizeHashedName(fileName) {
124
+ return normalizeHashedPath(fileName ?? '');
125
+ }
126
+
127
+ async function runWithNodeProvider(workspace) {
128
+ const buildResult = await frontendProvider.build({
129
+ workspaceRoot: workspace,
130
+ env: { WEBSTIR_MODULE_MODE: 'build' },
131
+ incremental: false,
132
+ });
133
+ const publishResult = await frontendProvider.build({
134
+ workspaceRoot: workspace,
135
+ env: { WEBSTIR_MODULE_MODE: 'publish' },
136
+ incremental: false,
137
+ });
138
+
139
+ return await snapshotWorkspace(
140
+ workspace,
141
+ publishResult.manifest.entryPoints.length > 0
142
+ ? publishResult.manifest.entryPoints
143
+ : buildResult.manifest.entryPoints,
144
+ );
145
+ }
146
+
147
+ async function runWithBunProvider(workspace) {
148
+ const packageRoot = getPackageRoot();
149
+ const moduleUrl = pathToFileURL(path.join(packageRoot, 'dist', 'index.js')).href;
150
+ const script = `
151
+ const workspace = process.argv[1];
152
+ const { frontendProvider } = await import(${JSON.stringify(moduleUrl)});
153
+ const buildResult = await frontendProvider.build({
154
+ workspaceRoot: workspace,
155
+ env: { WEBSTIR_MODULE_MODE: 'build', WEBSTIR_FRONTEND_BUNDLER: 'bun' },
156
+ incremental: false
157
+ });
158
+ const publishResult = await frontendProvider.build({
159
+ workspaceRoot: workspace,
160
+ env: { WEBSTIR_MODULE_MODE: 'publish', WEBSTIR_FRONTEND_BUNDLER: 'bun' },
161
+ incremental: false
162
+ });
163
+ const payload = {
164
+ entryPoints: publishResult.manifest.entryPoints.length > 0
165
+ ? publishResult.manifest.entryPoints
166
+ : buildResult.manifest.entryPoints
167
+ };
168
+ console.log('__RESULT__' + JSON.stringify(payload));
169
+ `;
170
+
171
+ const child = spawn('bun', ['--eval', script, workspace], {
172
+ cwd: packageRoot,
173
+ stdio: ['ignore', 'pipe', 'pipe'],
174
+ env: process.env,
175
+ });
176
+
177
+ let stdout = '';
178
+ let stderr = '';
179
+ child.stdout.setEncoding('utf8');
180
+ child.stderr.setEncoding('utf8');
181
+ child.stdout.on('data', (chunk) => {
182
+ stdout += chunk;
183
+ });
184
+ child.stderr.on('data', (chunk) => {
185
+ stderr += chunk;
186
+ });
187
+
188
+ const exitCode = await new Promise((resolve, reject) => {
189
+ child.once('error', reject);
190
+ child.once('close', resolve);
191
+ });
192
+
193
+ const resultLine = stdout
194
+ .split(/\r?\n/)
195
+ .map((line) => line.trim())
196
+ .filter((line) => line.startsWith('__RESULT__'))
197
+ .at(-1);
198
+
199
+ if (exitCode !== 0 || !resultLine) {
200
+ throw new Error(
201
+ `bun frontend parity harness failed (exit=${exitCode ?? 'null'})\nstdout:\n${stdout}\nstderr:\n${stderr}`,
202
+ );
203
+ }
204
+
205
+ const payload = JSON.parse(resultLine.slice('__RESULT__'.length));
206
+ return await snapshotWorkspace(workspace, payload.entryPoints ?? []);
207
+ }
208
+
209
+ async function compareBundlerSnapshots() {
210
+ const esbuildWorkspace = await createWorkspace('webstir-frontend-esbuild-');
211
+ const bunWorkspace = await createWorkspace('webstir-frontend-bun-');
212
+
213
+ const esbuildSnapshot = await runWithNodeProvider(esbuildWorkspace);
214
+ const bunSnapshot = await runWithBunProvider(bunWorkspace);
215
+
216
+ return { esbuildSnapshot, bunSnapshot };
217
+ }
218
+
219
+ test('build mode Bun bundler preserves frontend build output shape', async () => {
220
+ const { esbuildSnapshot, bunSnapshot } = await compareBundlerSnapshots();
221
+
222
+ assert.deepEqual(
223
+ bunSnapshot.entryPoints,
224
+ esbuildSnapshot.entryPoints,
225
+ 'build manifest entry points should stay aligned',
226
+ );
227
+ assert.deepEqual(
228
+ bunSnapshot.buildFiles,
229
+ esbuildSnapshot.buildFiles,
230
+ 'build/frontend output shape should stay aligned',
231
+ );
232
+ });
233
+
234
+ test('publish mode Bun bundler preserves frontend filename/hash resolution parity', async () => {
235
+ const { esbuildSnapshot, bunSnapshot } = await compareBundlerSnapshots();
236
+
237
+ assert.deepEqual(
238
+ bunSnapshot.distFiles,
239
+ esbuildSnapshot.distFiles,
240
+ 'dist/frontend output shape should stay aligned',
241
+ );
242
+ assert.equal(
243
+ bunSnapshot.sharedJs,
244
+ esbuildSnapshot.sharedJs,
245
+ 'shared app bundle name should keep the same hashed shape',
246
+ );
247
+ assert.equal(
248
+ bunSnapshot.pageJs,
249
+ esbuildSnapshot.pageJs,
250
+ 'page bundle name should keep the same hashed shape',
251
+ );
252
+ });
@@ -0,0 +1,13 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ test('frontend cli is emitted with a Bun shebang', async () => {
8
+ const here = path.dirname(fileURLToPath(import.meta.url));
9
+ const cliPath = path.join(here, '..', 'dist', 'cli.js');
10
+ const source = await fs.readFile(cliPath, 'utf8');
11
+
12
+ assert.match(source, /^#!\/usr\/bin\/env bun/m);
13
+ });