@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.
- package/README.md +124 -60
- package/dist/assets/imageOptimizer.js +10 -15
- package/dist/assets/precompression.js +1 -1
- package/dist/builders/contentBuilder.js +102 -90
- package/dist/builders/cssBuilder.js +25 -19
- package/dist/builders/htmlBuilder.js +57 -42
- package/dist/builders/index.js +1 -1
- package/dist/builders/jsBuilder.js +219 -76
- package/dist/builders/staticAssetsBuilder.js +27 -9
- package/dist/builders/types.d.ts +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -30
- package/dist/config/manifest.js +7 -6
- package/dist/config/paths.js +2 -2
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +7 -6
- package/dist/config/setup.js +1 -1
- package/dist/config/workspace.js +11 -9
- package/dist/core/constants.d.ts +1 -1
- package/dist/core/constants.js +5 -5
- package/dist/core/diagnostics.js +1 -1
- package/dist/core/pages.js +4 -4
- package/dist/hooks.js +3 -3
- package/dist/html/criticalCss.js +6 -3
- package/dist/html/htmlSecurity.d.ts +6 -1
- package/dist/html/htmlSecurity.js +28 -14
- package/dist/html/lazyLoad.js +1 -1
- package/dist/html/pageScaffold.js +1 -1
- package/dist/html/resourceHints.js +5 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inspect.d.ts +2 -0
- package/dist/inspect.js +110 -0
- package/dist/modes/ssg/metadata.js +4 -4
- package/dist/modes/ssg/routing.js +2 -5
- package/dist/modes/ssg/seo.js +5 -5
- package/dist/modes/ssg/views.js +17 -11
- package/dist/operations.js +18 -10
- package/dist/pipeline.d.ts +1 -0
- package/dist/pipeline.js +6 -1
- package/dist/provider.js +28 -24
- package/dist/runtime/boundary.d.ts +28 -0
- package/dist/runtime/boundary.js +247 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/utils/fs.d.ts +11 -10
- package/dist/utils/fs.js +48 -20
- package/dist/utils/glob.d.ts +8 -0
- package/dist/utils/glob.js +21 -0
- package/dist/utils/hash.js +1 -2
- package/dist/utils/pagePaths.js +2 -2
- package/package.json +19 -14
- package/scripts/publish.sh +2 -94
- package/scripts/update-contract.sh +12 -10
- package/src/assets/assetManifest.ts +39 -29
- package/src/assets/imageOptimizer.ts +91 -82
- package/src/assets/precompression.ts +22 -16
- package/src/builders/contentBuilder.ts +1224 -1149
- package/src/builders/cssBuilder.ts +466 -417
- package/src/builders/htmlBuilder.ts +511 -448
- package/src/builders/index.ts +7 -7
- package/src/builders/jsBuilder.ts +538 -280
- package/src/builders/staticAssetsBuilder.ts +166 -135
- package/src/builders/types.ts +7 -6
- package/src/cli.ts +66 -90
- package/src/config/manifest.ts +16 -14
- package/src/config/paths.ts +5 -5
- package/src/config/schema.ts +38 -37
- package/src/config/setup.ts +7 -7
- package/src/config/workspace.ts +118 -116
- package/src/config/workspaceManifest.ts +14 -14
- package/src/core/constants.ts +62 -62
- package/src/core/diagnostics.ts +26 -26
- package/src/core/pages.ts +19 -19
- package/src/hooks.ts +128 -118
- package/src/html/criticalCss.ts +84 -77
- package/src/html/htmlSecurity.ts +107 -66
- package/src/html/lazyLoad.ts +22 -19
- package/src/html/pageScaffold.ts +37 -28
- package/src/html/resourceHints.ts +83 -74
- package/src/index.ts +2 -0
- package/src/inspect.ts +158 -0
- package/src/modes/ssg/metadata.ts +53 -51
- package/src/modes/ssg/routing.ts +177 -177
- package/src/modes/ssg/seo.ts +208 -200
- package/src/modes/ssg/validation.ts +31 -25
- package/src/modes/ssg/views.ts +257 -238
- package/src/operations.ts +105 -95
- package/src/pipeline.ts +81 -69
- package/src/provider.ts +184 -176
- package/src/runtime/boundary.ts +325 -0
- package/src/runtime/index.ts +1 -0
- package/src/types.ts +107 -48
- package/src/utils/changedFile.ts +22 -22
- package/src/utils/fs.ts +73 -26
- package/src/utils/glob.ts +38 -0
- package/src/utils/hash.ts +2 -4
- package/src/utils/pagePaths.ts +35 -23
- package/src/utils/pathMatch.ts +26 -23
- package/tests/add-page-defaults.test.js +44 -39
- package/tests/bundlerParity.test.js +252 -0
- package/tests/cli.contract.test.js +13 -0
- package/tests/content-pages.test.js +108 -13
- package/tests/css-app-imports.test.js +22 -11
- package/tests/css-page-imports.test.js +26 -13
- package/tests/diagnostics.test.js +39 -36
- package/tests/features.test.js +48 -43
- package/tests/hooks.test.js +58 -42
- package/tests/htmlSecurity.test.js +66 -0
- package/tests/inspect.test.js +148 -0
- package/tests/provider.integration.test.js +71 -20
- package/tests/runtime.test.js +493 -0
- package/tests/ssg-defaults.test.js +284 -177
- package/tests/ssg-guardrails.test.js +51 -51
- package/tsconfig.json +3 -10
- package/dist/watch/frontendFiles.d.ts +0 -3
- package/dist/watch/frontendFiles.js +0 -25
- package/dist/watch/hotUpdateTracker.d.ts +0 -51
- package/dist/watch/hotUpdateTracker.js +0 -205
- package/dist/watch/pipelineHelpers.d.ts +0 -26
- package/dist/watch/pipelineHelpers.js +0 -177
- package/dist/watch/types.d.ts +0 -27
- package/dist/watch/types.js +0 -1
- package/dist/watch/watchCoordinator.d.ts +0 -36
- package/dist/watch/watchCoordinator.js +0 -551
- package/dist/watch/watchDaemon.d.ts +0 -17
- package/dist/watch/watchDaemon.js +0 -127
- package/dist/watch/watchReporter.d.ts +0 -21
- package/dist/watch/watchReporter.js +0 -64
- package/scripts/smoke.mjs +0 -35
- package/src/watch/frontendFiles.ts +0 -32
- package/src/watch/hotUpdateTracker.ts +0 -285
- package/src/watch/pipelineHelpers.ts +0 -242
- package/src/watch/types.ts +0 -23
- package/src/watch/watchCoordinator.ts +0 -666
- package/src/watch/watchDaemon.ts +0 -144
- 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
|
-
|
|
5
|
-
|
|
2
|
+
const hash = new Bun.CryptoHasher('sha256').update(content).digest('hex');
|
|
3
|
+
return hash.slice(0, length);
|
|
6
4
|
}
|
package/src/utils/pagePaths.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
+
return resolvePagesUrlPrefix(frontendRoot, pagesRoot) === '';
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function resolvePageAssetUrl(
|
|
17
|
-
|
|
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(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
54
|
+
return value.replace(/^\/+|\/+$/g, '');
|
|
43
55
|
}
|
package/src/utils/pathMatch.ts
CHANGED
|
@@ -1,36 +1,39 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
export function isInsideDirectory(filePath: string, directory: string): boolean {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
if (!filePath) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
if (!isInsideDirectory(filePath, directory)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
34
37
|
|
|
35
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
const workspace = await createWorkspace({
|
|
18
|
+
name: 'webstir-project',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
webstir: { mode: 'ssg' },
|
|
21
|
+
});
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
try {
|
|
24
|
+
await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
assert.equal(fssync.existsSync(htmlPath), true);
|
|
32
|
+
assert.equal(fssync.existsSync(cssPath), true);
|
|
33
|
+
assert.equal(fssync.existsSync(tsPath), false);
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const workspace = await createWorkspace({
|
|
47
|
+
name: 'webstir-project',
|
|
48
|
+
version: '1.0.0',
|
|
49
|
+
});
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
try {
|
|
52
|
+
await runAddPage({ workspaceRoot: workspace, pageName: 'about' });
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
58
|
+
assert.equal(fssync.existsSync(htmlPath), true);
|
|
59
|
+
assert.equal(fssync.existsSync(tsPath), true);
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
});
|