@akanjs/devkit 3.0.0-beta.9 → 3.0.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/CHANGELOG.md +1819 -0
- package/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +21 -7
- package/README.md +17 -8
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +439 -141
- package/akanConfig/akanConfig.ts +359 -275
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +22 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +34 -31
- package/artifact/implicitRootLayout.ts +60 -107
- package/artifact/routeSeedIndex.test.ts +5 -18
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +39 -2
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +71 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/csrE2e/csrE2eHarness.fixture.ts +506 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +265 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +37 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +111 -202
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +67 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +43 -0
- package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
- package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-static-in-object-light-model.grit +27 -0
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +182 -0
- package/mobile/desktopBin.ts +144 -0
- package/mobile/desktopServerStage.test.ts +252 -0
- package/mobile/desktopServerStage.ts +178 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +184 -0
- package/mobile/nativeApp.ts +454 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +438 -0
- package/mobile/nativeConfig.ts +294 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +19 -26
- package/subspace.ts +48 -115
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -1,24 +1,17 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { chmod, mkdir,
|
|
3
|
-
import os from "node:os";
|
|
2
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import type { Logger } from "akanjs/common";
|
|
5
|
+
import { tempDirs } from "../testHelpers";
|
|
6
6
|
import { type ChangeBatch, HmrWatcher } from "./hmrWatcher";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
* These run against the real Bun watcher rather than a fake, because the behaviour under test only
|
|
10
|
-
* exists there: Bun 1.3.14's recursive `fs.watch` reports about one path per ~200ms coalescing window and
|
|
11
|
-
* discards the rest (`local/optimize-resource/06-watcher-dropped-event.md`). A fake that delivered every
|
|
12
|
-
* event would pass no matter what the watcher did with them.
|
|
13
|
-
*/
|
|
8
|
+
// The real Bun watcher, not a fake: the dropped-event behaviour under test only exists there.
|
|
14
9
|
const STREAM_WARMUP_MS = 600;
|
|
15
10
|
const SETTLE_MS = 1_500;
|
|
16
11
|
const TEST_TIMEOUT_MS = 15_000;
|
|
17
12
|
|
|
18
13
|
const started: HmrWatcher[] = [];
|
|
19
|
-
const roots: string[] = [];
|
|
20
14
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
-
/** Every level `HmrWatcher` can reach, so adding a log line cannot fail a test for the wrong reason. */
|
|
22
15
|
const silentLogger = {
|
|
23
16
|
trace: () => undefined,
|
|
24
17
|
verbose: () => undefined,
|
|
@@ -29,12 +22,6 @@ const silentLogger = {
|
|
|
29
22
|
error: () => undefined,
|
|
30
23
|
} as unknown as Logger;
|
|
31
24
|
|
|
32
|
-
const makeRoot = async () => {
|
|
33
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "akan-hmr-watcher-"));
|
|
34
|
-
roots.push(root);
|
|
35
|
-
return root;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
25
|
const seed = async (root: string, rel: string, content = "export const x = 1;\n") => {
|
|
39
26
|
const abs = path.join(root, rel);
|
|
40
27
|
await mkdir(path.dirname(abs), { recursive: true });
|
|
@@ -56,12 +43,63 @@ const watch = async (root: string) => {
|
|
|
56
43
|
return { watcher, batches, seen: () => new Set(batches.flatMap((batch) => batch.files)) };
|
|
57
44
|
};
|
|
58
45
|
|
|
59
|
-
afterEach(
|
|
46
|
+
afterEach(() => {
|
|
60
47
|
for (const watcher of started.splice(0)) watcher.stop();
|
|
61
|
-
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
62
48
|
});
|
|
49
|
+
const makeRoot = tempDirs("akan-hmr-watcher-");
|
|
63
50
|
|
|
64
51
|
describe("HmrWatcher", () => {
|
|
52
|
+
test(
|
|
53
|
+
"marks when a batch's window opened and when it was handed over",
|
|
54
|
+
async () => {
|
|
55
|
+
const root = await makeRoot();
|
|
56
|
+
const source = await seed(root, "lib/a.ts");
|
|
57
|
+
const { batches } = await watch(root);
|
|
58
|
+
|
|
59
|
+
const writtenAt = Date.now();
|
|
60
|
+
await writeFile(source, "export const x = 2;\n");
|
|
61
|
+
await sleep(SETTLE_MS);
|
|
62
|
+
|
|
63
|
+
const trace = batches.filter((batch) => batch.files.includes(source)).at(-1)?.trace;
|
|
64
|
+
expect(trace?.eventAt).toBeGreaterThanOrEqual(writtenAt);
|
|
65
|
+
expect(trace?.flushAt).toBeGreaterThanOrEqual(trace?.eventAt ?? Number.POSITIVE_INFINITY);
|
|
66
|
+
},
|
|
67
|
+
TEST_TIMEOUT_MS,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
test(
|
|
71
|
+
"takes its debounce window from AKAN_DEV_WATCH_DEBOUNCE_MS when no option names one",
|
|
72
|
+
async () => {
|
|
73
|
+
const previous = process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
|
|
74
|
+
process.env.AKAN_DEV_WATCH_DEBOUNCE_MS = "400";
|
|
75
|
+
try {
|
|
76
|
+
const root = await makeRoot();
|
|
77
|
+
const source = await seed(root, "lib/a.ts");
|
|
78
|
+
const batches: ChangeBatch[] = [];
|
|
79
|
+
// Without the verification scan, which would otherwise hand the batch over at 250ms.
|
|
80
|
+
const watcher = new HmrWatcher({
|
|
81
|
+
roots: [root],
|
|
82
|
+
logger: silentLogger,
|
|
83
|
+
verifyDelayMs: 0,
|
|
84
|
+
onBatch: (batch) => void batches.push(batch),
|
|
85
|
+
});
|
|
86
|
+
started.push(watcher);
|
|
87
|
+
await watcher.start();
|
|
88
|
+
await sleep(STREAM_WARMUP_MS);
|
|
89
|
+
|
|
90
|
+
await writeFile(source, "export const x = 3;\n");
|
|
91
|
+
await sleep(SETTLE_MS);
|
|
92
|
+
|
|
93
|
+
const trace = batches.find((batch) => batch.files.includes(source))?.trace;
|
|
94
|
+
expect((trace?.flushAt ?? 0) - (trace?.eventAt ?? 0)).toBeGreaterThanOrEqual(395);
|
|
95
|
+
} finally {
|
|
96
|
+
if (previous === undefined) delete process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
|
|
97
|
+
else process.env.AKAN_DEV_WATCH_DEBOUNCE_MS = previous;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
TEST_TIMEOUT_MS,
|
|
101
|
+
);
|
|
102
|
+
|
|
65
103
|
test(
|
|
66
104
|
"reports every file of a save-all",
|
|
67
105
|
async () => {
|
|
@@ -69,15 +107,12 @@ describe("HmrWatcher", () => {
|
|
|
69
107
|
const files = await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
|
|
70
108
|
const { batches, seen, watcher } = await watch(root);
|
|
71
109
|
|
|
72
|
-
// No gaps between writes, so they share one coalescing window and Bun names at most one of them.
|
|
73
110
|
for (const [i, abs] of files.entries()) await writeFile(abs, `export const x = ${i}00;\n`);
|
|
74
111
|
await sleep(SETTLE_MS);
|
|
75
112
|
|
|
76
113
|
expect(batches.length).toBeGreaterThan(0);
|
|
77
114
|
for (const abs of files) expect([...seen()]).toContain(abs);
|
|
78
|
-
// Not
|
|
79
|
-
// events, so this suite keeps passing if the upstream defect is ever fixed. The counter is the
|
|
80
|
-
// signal for when the compensation can be removed.
|
|
115
|
+
// Not `> 0`: the suite must keep passing if Bun ever stops dropping events.
|
|
81
116
|
expect(watcher.unreportedChanges).toBeGreaterThanOrEqual(0);
|
|
82
117
|
},
|
|
83
118
|
TEST_TIMEOUT_MS,
|
|
@@ -90,8 +125,6 @@ describe("HmrWatcher", () => {
|
|
|
90
125
|
const source = await seed(root, "lib/a.ts");
|
|
91
126
|
const { seen, batches } = await watch(root);
|
|
92
127
|
|
|
93
|
-
// What every build ends with: a burst under `.akan/`, which the classifier ignores. Before this
|
|
94
|
-
// was handled, the burst was the one path Bun reported and the save right after it was invisible.
|
|
95
128
|
const artifactDir = path.join(root, ".akan", "artifact", "server");
|
|
96
129
|
await mkdir(artifactDir, { recursive: true });
|
|
97
130
|
for (let i = 0; i < 60; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(8192));
|
|
@@ -100,8 +133,6 @@ describe("HmrWatcher", () => {
|
|
|
100
133
|
await sleep(SETTLE_MS);
|
|
101
134
|
|
|
102
135
|
expect([...seen()]).toContain(source);
|
|
103
|
-
// Exactly one batch for one save. Bun does deliver a real event for some paths the scan has already
|
|
104
|
-
// emitted, and counting both produced a second generation and a second build for no change.
|
|
105
136
|
expect(batches.filter((batch) => batch.files.includes(source))).toHaveLength(1);
|
|
106
137
|
},
|
|
107
138
|
TEST_TIMEOUT_MS,
|
|
@@ -119,7 +150,6 @@ describe("HmrWatcher", () => {
|
|
|
119
150
|
for (let i = 0; i < 20; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(4096));
|
|
120
151
|
await sleep(SETTLE_MS);
|
|
121
152
|
|
|
122
|
-
// The verification scan is triggered by ignored paths, so it must not invent work from them either.
|
|
123
153
|
expect(batches).toEqual([]);
|
|
124
154
|
},
|
|
125
155
|
TEST_TIMEOUT_MS,
|
|
@@ -161,7 +191,7 @@ describe("HmrWatcher", () => {
|
|
|
161
191
|
TEST_TIMEOUT_MS,
|
|
162
192
|
);
|
|
163
193
|
|
|
164
|
-
test.skipIf(process.getuid?.() === 0)(
|
|
194
|
+
test.skipIf(process.getuid?.() === 0 || process.platform === "win32")(
|
|
165
195
|
"warns at startup when part of the tree cannot be read",
|
|
166
196
|
async () => {
|
|
167
197
|
const root = await makeRoot();
|
|
@@ -176,12 +206,9 @@ describe("HmrWatcher", () => {
|
|
|
176
206
|
});
|
|
177
207
|
started.push(watcher);
|
|
178
208
|
await watcher.start();
|
|
179
|
-
// Restored before asserting
|
|
180
|
-
// cannot traverse, and the leak would fail the *next* test instead of this one.
|
|
209
|
+
// Restored before asserting: a failed assertion would leave a directory `rm` cannot traverse.
|
|
181
210
|
await chmod(path.dirname(hidden), 0o755);
|
|
182
211
|
|
|
183
|
-
// At startup rather than at the first save, because an unreadable root means edits under it never
|
|
184
|
-
// rebuild — waiting for a save to reveal that means waiting for the save that silently does nothing.
|
|
185
212
|
expect(warnings).toHaveLength(1);
|
|
186
213
|
expect(warnings[0]).toContain("will not rebuild");
|
|
187
214
|
expect(warnings[0]).toContain("EACCES");
|
|
@@ -12,24 +12,12 @@ export interface WatcherOptions {
|
|
|
12
12
|
debounceMs?: number;
|
|
13
13
|
logger: Logger;
|
|
14
14
|
onBatch: (batch: ChangeBatch) => void | Promise<void>;
|
|
15
|
-
/**
|
|
16
|
-
* Delay before re-checking mtimes for changes `fs.watch` never reported. Must clear Bun's ~200ms
|
|
17
|
-
* coalescing window, or a write late in the same window as a delivered event stays invisible.
|
|
18
|
-
*/
|
|
15
|
+
/** Delay (ms, default 250) before the mtime re-check; must outlast Bun's ~200ms fs.watch coalescing window. */
|
|
19
16
|
verifyDelayMs?: number;
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* classification coarse (`code` / `css` / `config`) so the orchestrator can
|
|
25
|
-
* decide whether a full rebuild-and-reload or a narrower action (e.g. CSS
|
|
26
|
-
* hot-swap) is sufficient.
|
|
27
|
-
*
|
|
28
|
-
* Bun's event payloads are treated as a hint, not the answer: its recursive `fs.watch` reports about one
|
|
29
|
-
* path per coalescing window and silently discards the rest, so what changed is resolved against a
|
|
30
|
-
* `SourceMtimeIndex` instead (see that class for the measurements). Events still drive *when* to look,
|
|
31
|
-
* which is what keeps this cheap — Bun does reliably deliver at least one event per window.
|
|
32
|
-
*/
|
|
19
|
+
// Bun delivers at least one fs.watch event per coalescing window but drops the other paths, so events decide
|
|
20
|
+
// when to look and the SourceMtimeIndex decides what changed.
|
|
33
21
|
export class HmrWatcher {
|
|
34
22
|
readonly #roots: string[];
|
|
35
23
|
readonly #debounceMs: number;
|
|
@@ -38,12 +26,12 @@ export class HmrWatcher {
|
|
|
38
26
|
readonly #logger: Logger;
|
|
39
27
|
readonly #watchers: fs.FSWatcher[] = [];
|
|
40
28
|
readonly #pending = new Map<string, Exclude<ChangeKind, "ignore">>();
|
|
41
|
-
/** Paths the watcher's own events named since the last batch — diagnostics only, see `#queue`. */
|
|
42
29
|
readonly #hinted = new Set<string>();
|
|
43
30
|
readonly #classifier = new HmrChangeClassifier();
|
|
44
31
|
readonly #index: SourceMtimeIndex;
|
|
45
32
|
#timer: ReturnType<typeof setTimeout> | null = null;
|
|
46
33
|
#verifyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
#windowStartedAt: number | null = null;
|
|
47
35
|
#stopped = false;
|
|
48
36
|
#flushing = false;
|
|
49
37
|
#unreportedChanges = 0;
|
|
@@ -52,26 +40,19 @@ export class HmrWatcher {
|
|
|
52
40
|
|
|
53
41
|
constructor(opts: WatcherOptions) {
|
|
54
42
|
this.#roots = [...new Set(opts.roots.map((r) => path.resolve(r)))];
|
|
55
|
-
this.#debounceMs = opts.debounceMs ??
|
|
43
|
+
this.#debounceMs = opts.debounceMs ?? HmrWatcher.#envDebounceMs() ?? 30;
|
|
56
44
|
this.#verifyDelayMs = opts.verifyDelayMs ?? 250;
|
|
57
45
|
this.#onBatch = opts.onBatch;
|
|
58
46
|
this.#logger = opts.logger;
|
|
59
47
|
this.#index = new SourceMtimeIndex({ roots: this.#roots, classifier: this.#classifier });
|
|
60
48
|
}
|
|
61
49
|
|
|
62
|
-
/**
|
|
63
|
-
* How many changes the mtime scan found that `fs.watch` never reported. Non-zero means this watcher is
|
|
64
|
-
* compensating for the Bun defect rather than the defect being absent, which is the number to watch if
|
|
65
|
-
* it is ever fixed upstream.
|
|
66
|
-
*/
|
|
50
|
+
/** Changes the mtime scan found that fs.watch never reported; non-zero means it is compensating for Bun. */
|
|
67
51
|
get unreportedChanges(): number {
|
|
68
52
|
return this.#unreportedChanges;
|
|
69
53
|
}
|
|
70
54
|
|
|
71
|
-
/**
|
|
72
|
-
* Watchers are installed before the baseline is taken, so an edit made during priming is reported by
|
|
73
|
-
* the event side even though the baseline already reflects it.
|
|
74
|
-
*/
|
|
55
|
+
/** Installs the watchers before priming, so an edit made while priming is still reported by the event side. */
|
|
75
56
|
async start(): Promise<void> {
|
|
76
57
|
for (const root of this.#roots) {
|
|
77
58
|
try {
|
|
@@ -80,6 +61,12 @@ export class HmrWatcher {
|
|
|
80
61
|
const abs = path.resolve(root, filename.toString());
|
|
81
62
|
this.#queue(abs);
|
|
82
63
|
});
|
|
64
|
+
//? Linux emits an unreadable subdirectory as an async EPERM error event, which throws when unhandled.
|
|
65
|
+
//? The coverage-gap scan already warns about it, so only other errors are worth a warning here.
|
|
66
|
+
w.on("error", (err: NodeJS.ErrnoException) => {
|
|
67
|
+
if (err.code === "EACCES" || err.code === "EPERM") this.#logger.verbose(`[hmr] ${err.message}`);
|
|
68
|
+
else this.#logger.warn(`[hmr] watcher error under ${root}: ${err.message}`);
|
|
69
|
+
});
|
|
83
70
|
this.#watchers.push(w);
|
|
84
71
|
this.#logger.verbose(`[hmr] watching ${root}`);
|
|
85
72
|
} catch (err) {
|
|
@@ -88,8 +75,7 @@ export class HmrWatcher {
|
|
|
88
75
|
}
|
|
89
76
|
try {
|
|
90
77
|
await this.#index.prime();
|
|
91
|
-
//
|
|
92
|
-
// waiting for a save to surface it means waiting for a save that never rebuilds.
|
|
78
|
+
// At boot: edits under an unreadable root never rebuild, so no later save would surface it.
|
|
93
79
|
this.#reportCoverageGaps();
|
|
94
80
|
this.#logger.verbose(`[hmr] tracking ${this.#index.trackedFileCount} source files for change verification`);
|
|
95
81
|
} catch (err) {
|
|
@@ -112,23 +98,14 @@ export class HmrWatcher {
|
|
|
112
98
|
}
|
|
113
99
|
}
|
|
114
100
|
|
|
115
|
-
/**
|
|
116
|
-
* Adopt writes the batch handler made itself (regenerated barrels, inserted imports) so the
|
|
117
|
-
* verification scan does not spend a second generation rebuilding content this one already consumed.
|
|
118
|
-
*/
|
|
101
|
+
/** Baseline the batch handler's own writes so the verification scan does not rebuild them a second time. */
|
|
119
102
|
async absorb(paths: string[]): Promise<void> {
|
|
120
103
|
if (paths.length === 0) return;
|
|
121
104
|
await this.#index.absorb(paths);
|
|
122
105
|
}
|
|
123
106
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
* used purely to decide when to look, and the mtime scan names the files.
|
|
127
|
-
*
|
|
128
|
-
* Taking the payload as well would double-report: Bun does deliver a real event for some of the paths a
|
|
129
|
-
* scan has already emitted, and adding it back here produced a second batch for the same save — one more
|
|
130
|
-
* generation and one more build for no change.
|
|
131
|
-
*/
|
|
107
|
+
// Once primed, a payload only schedules a scan: Bun also delivers events for paths the scan already emitted,
|
|
108
|
+
// so adding payloads to the batch double-reports a save.
|
|
132
109
|
#queue(abs: string): void {
|
|
133
110
|
const kind = this.#classifier.classify(abs);
|
|
134
111
|
if (!this.#index.primed) {
|
|
@@ -138,20 +115,18 @@ export class HmrWatcher {
|
|
|
138
115
|
this.#scheduleFlush();
|
|
139
116
|
return;
|
|
140
117
|
}
|
|
141
|
-
// An ignored path
|
|
142
|
-
// ends in a burst under `.akan/`, and the burst is what Bun reports instead of the save beside it.
|
|
143
|
-
// Rescheduling on each one folds a build's whole burst into a single scan once it goes quiet.
|
|
118
|
+
// An ignored path (a build's .akan/ burst) still marks a coalescing window that may hide a real save.
|
|
144
119
|
if (kind === "ignore") {
|
|
145
120
|
this.#scheduleVerify();
|
|
146
121
|
return;
|
|
147
122
|
}
|
|
148
|
-
//
|
|
149
|
-
// what is in a batch.
|
|
123
|
+
// Diagnostics only, for `unreportedChanges`; it never decides what is in a batch.
|
|
150
124
|
this.#hinted.add(abs);
|
|
151
125
|
this.#scheduleFlush();
|
|
152
126
|
}
|
|
153
127
|
|
|
154
128
|
#scheduleFlush(): void {
|
|
129
|
+
this.#windowStartedAt ??= Date.now();
|
|
155
130
|
if (this.#flushing) return;
|
|
156
131
|
if (this.#timer) clearTimeout(this.#timer);
|
|
157
132
|
this.#timer = setTimeout(() => this.#flush(), this.#debounceMs);
|
|
@@ -168,13 +143,19 @@ export class HmrWatcher {
|
|
|
168
143
|
try {
|
|
169
144
|
while (!this.#stopped) {
|
|
170
145
|
await this.#mergeDetectedChanges();
|
|
171
|
-
if (this.#pending.size === 0)
|
|
146
|
+
if (this.#pending.size === 0) {
|
|
147
|
+
this.#windowStartedAt = null;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
172
150
|
const files = Array.from(this.#pending.keys());
|
|
173
151
|
const kinds = new Set(this.#pending.values());
|
|
152
|
+
const flushAt = Date.now();
|
|
153
|
+
const trace = { eventAt: this.#windowStartedAt ?? flushAt, flushAt };
|
|
154
|
+
this.#windowStartedAt = null;
|
|
174
155
|
this.#pending.clear();
|
|
175
156
|
this.#hinted.clear();
|
|
176
157
|
try {
|
|
177
|
-
await this.#onBatch({ files, kinds });
|
|
158
|
+
await this.#onBatch({ files, kinds, trace });
|
|
178
159
|
} catch (e) {
|
|
179
160
|
this.#logger.error(`[hmr] onBatch error: ${(e as Error).message}`);
|
|
180
161
|
}
|
|
@@ -186,10 +167,6 @@ export class HmrWatcher {
|
|
|
186
167
|
}
|
|
187
168
|
}
|
|
188
169
|
|
|
189
|
-
/**
|
|
190
|
-
* Fold in everything the mtime index has seen change, since a delivered event names at most one of the
|
|
191
|
-
* paths that moved in its window.
|
|
192
|
-
*/
|
|
193
170
|
async #mergeDetectedChanges(): Promise<void> {
|
|
194
171
|
const detected = await this.#index.collectChanges().catch((err) => {
|
|
195
172
|
this.#logger.error(`[hmr] mtime scan failed: ${(err as Error).message}`);
|
|
@@ -205,8 +182,6 @@ export class HmrWatcher {
|
|
|
205
182
|
}
|
|
206
183
|
if (unreported === 0) return;
|
|
207
184
|
this.#unreportedChanges += unreported;
|
|
208
|
-
// Once at info, so it is visible that the watcher is compensating rather than the defect being absent;
|
|
209
|
-
// per-batch detail stays at verbose because a save-all trips this on every save.
|
|
210
185
|
if (!this.#reportedCompensating) {
|
|
211
186
|
this.#reportedCompensating = true;
|
|
212
187
|
this.#logger.verbose(
|
|
@@ -218,13 +193,6 @@ export class HmrWatcher {
|
|
|
218
193
|
);
|
|
219
194
|
}
|
|
220
195
|
|
|
221
|
-
/**
|
|
222
|
-
* Say so when part of the tree cannot be read, and say so again when it recovers.
|
|
223
|
-
*
|
|
224
|
-
* A blind spot here means edits under that path are not rebuilt at all, which is indistinguishable from
|
|
225
|
-
* the dev server being broken. Keyed on the gap list itself so a persistent failure logs once rather than
|
|
226
|
-
* once per save, while a *different* gap appearing still gets its own line.
|
|
227
|
-
*/
|
|
228
196
|
#reportCoverageGaps(): void {
|
|
229
197
|
const gaps = this.#index.coverageGaps;
|
|
230
198
|
const key = gaps
|
|
@@ -247,10 +215,13 @@ export class HmrWatcher {
|
|
|
247
215
|
);
|
|
248
216
|
}
|
|
249
217
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
218
|
+
static #envDebounceMs(): number | null {
|
|
219
|
+
const raw = process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
|
|
220
|
+
const ms = raw ? Number(raw) : Number.NaN;
|
|
221
|
+
return Number.isInteger(ms) && ms >= 0 ? ms : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A write in the same window as a delivered event raises no event of its own, so one scan follows each window.
|
|
254
225
|
#scheduleVerify(): void {
|
|
255
226
|
if (this.#stopped || this.#verifyDelayMs <= 0) return;
|
|
256
227
|
if (this.#verifyTimer) clearTimeout(this.#verifyTimer);
|
|
@@ -260,16 +231,8 @@ export class HmrWatcher {
|
|
|
260
231
|
}, this.#verifyDelayMs);
|
|
261
232
|
}
|
|
262
233
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
* artifacts under `.akan/` — which the classifier ignores — while codegen writes are content-guarded,
|
|
266
|
-
* so a rebuild does not move a tracked mtime.
|
|
267
|
-
*
|
|
268
|
-
* The one case that does schedule another is a directory the index could not date reliably (Linux stamps
|
|
269
|
-
* directory mtimes on a 1ms clock, see `SourceMtimeIndex`). That still terminates: the next scan is
|
|
270
|
-
* `verifyDelayMs` later, by which point the timestamp has settled unless something is writing to that
|
|
271
|
-
* directory right now — in which case looking again is the right answer anyway.
|
|
272
|
-
*/
|
|
234
|
+
// Terminates: build output lands in ignored .akan/ and codegen writes are content-guarded, so a rebuild moves no
|
|
235
|
+
// tracked mtime; only an unsettled directory schedules another scan.
|
|
273
236
|
async #verify(): Promise<void> {
|
|
274
237
|
if (this.#stopped || this.#flushing) return;
|
|
275
238
|
await this.#mergeDetectedChanges();
|
package/frontendBuild/index.ts
CHANGED
|
@@ -4,10 +4,12 @@ export * from "./clientBuildTypes";
|
|
|
4
4
|
export * from "./clientEntriesBundler";
|
|
5
5
|
export * from "./clientEntryDiscovery";
|
|
6
6
|
export * from "./csrArtifactBuilder";
|
|
7
|
+
export * from "./csrDevBundler";
|
|
7
8
|
export * from "./cssCompiler";
|
|
8
9
|
export * from "./cssImportResolver";
|
|
9
10
|
export * from "./devChangePlanner";
|
|
10
11
|
export * from "./devGeneratedIndexSync";
|
|
12
|
+
export * from "./devRegistryBundler";
|
|
11
13
|
export * from "./fontOptimizer";
|
|
12
14
|
export * from "./fontPruner";
|
|
13
15
|
export * from "./hmrChangeClassifier";
|
|
@@ -17,8 +19,10 @@ export * from "./pagesEntrySourceGenerator";
|
|
|
17
19
|
export * from "./precompressArtifacts";
|
|
18
20
|
export * from "./routeClientBuilder";
|
|
19
21
|
export * from "./routesManifestArtifactSerializer";
|
|
22
|
+
export * from "./serverGraphFile";
|
|
20
23
|
export * from "./sourceMtimeIndex";
|
|
21
24
|
export * from "./ssrBaseArtifactBuilder";
|
|
25
|
+
export * from "./ssrDevBundler";
|
|
22
26
|
export * from "./styleContract";
|
|
23
27
|
export * from "./themeValidator";
|
|
24
28
|
export * from "./vendorSpecifiers";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type { BunPlugin } from "bun";
|
|
3
4
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
@@ -5,18 +6,17 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
|
5
6
|
import type { App } from "../commandDecorators";
|
|
6
7
|
import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
|
|
7
8
|
import { createExternalizeFrameworkPlugin } from "../transforms/externalizeFrameworkPlugin";
|
|
9
|
+
import { loaderFor } from "../transforms/moduleSyntax";
|
|
8
10
|
import { transformUseClient } from "../transforms/rscUseClientTransform";
|
|
9
11
|
import { createUseClientBundlePlugin } from "../transforms/useClientBundlePlugin";
|
|
12
|
+
import { bundleDefine } from "./bundleDefine";
|
|
13
|
+
import { CsrDevPaths } from "./csrDevPaths";
|
|
10
14
|
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
15
|
+
import { ServerGraphFile } from "./serverGraphFile";
|
|
11
16
|
|
|
12
17
|
export interface BuildPagesBundleResult {
|
|
13
|
-
/** Absolute path to the emitted `pages-[hash].js`. */
|
|
14
18
|
bundlePath: string;
|
|
15
|
-
/**
|
|
16
|
-
* Monotonic build identifier. Bun.build emits a fresh filename whenever
|
|
17
|
-
* any input changes, but importers still benefit from a `?v=<buildId>`
|
|
18
|
-
* query-string cache bust — `buildId` is that value.
|
|
19
|
-
*/
|
|
19
|
+
/** Cache-bust value for `import(bundlePath?v=<buildId>)`. */
|
|
20
20
|
buildId: number;
|
|
21
21
|
splitting: boolean;
|
|
22
22
|
entryBytes: number;
|
|
@@ -27,10 +27,6 @@ export interface BuildPagesBundleResult {
|
|
|
27
27
|
|
|
28
28
|
const VIRTUAL_PAGES_ENTRY = "akan-pages-entry";
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* Build the server-side pages bundle. The RSC worker loads the result with
|
|
32
|
-
* `await import(bundlePath?v=buildId)`.
|
|
33
|
-
*/
|
|
34
30
|
export class PagesBundleBuilder {
|
|
35
31
|
#app: App;
|
|
36
32
|
#command: "build" | "start";
|
|
@@ -49,6 +45,13 @@ export class PagesBundleBuilder {
|
|
|
49
45
|
this.#pageEntries ?? (await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys()));
|
|
50
46
|
const entrySource = PagesEntrySourceGenerator.generate(resolvedEntries);
|
|
51
47
|
const workspaceRoot = this.#app.workspace.workspaceRoot;
|
|
48
|
+
const dev = this.#command === "start";
|
|
49
|
+
const clientExports: Record<string, string[]> = {};
|
|
50
|
+
const onClientModule = dev
|
|
51
|
+
? (file: string, exports: string[]) => {
|
|
52
|
+
clientExports[CsrDevPaths.realpath(file)] = exports;
|
|
53
|
+
}
|
|
54
|
+
: undefined;
|
|
52
55
|
const result = await Bun.build({
|
|
53
56
|
entrypoints: [VIRTUAL_PAGES_ENTRY],
|
|
54
57
|
outdir: `${this.#artifactDir}/server`,
|
|
@@ -61,7 +64,8 @@ export class PagesBundleBuilder {
|
|
|
61
64
|
chunk: "chunks/[name]-[hash].[ext]",
|
|
62
65
|
asset: "assets/[name]-[hash].[ext]",
|
|
63
66
|
},
|
|
64
|
-
define: this.#
|
|
67
|
+
define: bundleDefine(this.#app, this.#command, "ssr"),
|
|
68
|
+
metafile: dev,
|
|
65
69
|
plugins: [
|
|
66
70
|
PagesBundleBuilder.createPagesEntryPlugin(entrySource),
|
|
67
71
|
PagesBundleBuilder.createCssStubPlugin(),
|
|
@@ -73,9 +77,10 @@ export class PagesBundleBuilder {
|
|
|
73
77
|
transformUseClient(source, {
|
|
74
78
|
path: args.path,
|
|
75
79
|
workspaceRoot,
|
|
80
|
+
onClientModule,
|
|
76
81
|
}),
|
|
77
82
|
})
|
|
78
|
-
: createUseClientBundlePlugin({ workspaceRoot }),
|
|
83
|
+
: createUseClientBundlePlugin({ workspaceRoot, onClientModule }),
|
|
79
84
|
],
|
|
80
85
|
});
|
|
81
86
|
|
|
@@ -86,6 +91,8 @@ export class PagesBundleBuilder {
|
|
|
86
91
|
|
|
87
92
|
const bundlePath = path.resolve(entryArtifact.path);
|
|
88
93
|
const buildId = Date.now();
|
|
94
|
+
if (dev && result.metafile)
|
|
95
|
+
await ServerGraphFile.write(this.#artifactDir, this.#serverGraph(result.metafile, clientExports));
|
|
89
96
|
const outputBytes = result.outputs.reduce((sum, output) => sum + output.size, 0);
|
|
90
97
|
const chunkCount = result.outputs.filter((output) => output.kind === "chunk").length;
|
|
91
98
|
this.#app.verbose(
|
|
@@ -102,6 +109,15 @@ export class PagesBundleBuilder {
|
|
|
102
109
|
};
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
#serverGraph(metafile: Bun.BuildMetafile, clientExports: Record<string, string[]>) {
|
|
113
|
+
const inputs = Object.keys(metafile.inputs)
|
|
114
|
+
.filter((input) => !input.includes("node_modules"))
|
|
115
|
+
.map((input) => path.resolve(input))
|
|
116
|
+
.filter((input) => fs.existsSync(input))
|
|
117
|
+
.map((input) => CsrDevPaths.realpath(input));
|
|
118
|
+
return { inputs, clientExports };
|
|
119
|
+
}
|
|
120
|
+
|
|
105
121
|
get #artifactDir(): string {
|
|
106
122
|
return `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
|
|
107
123
|
}
|
|
@@ -110,17 +126,6 @@ export class PagesBundleBuilder {
|
|
|
110
126
|
return process.env.AKAN_SERVER_PAGES_SPLITTING === "1";
|
|
111
127
|
}
|
|
112
128
|
|
|
113
|
-
#define(): Record<string, string> {
|
|
114
|
-
const nodeEnv = this.#command === "build" ? "production" : (process.env.NODE_ENV ?? "development");
|
|
115
|
-
return {
|
|
116
|
-
"process.env.NODE_ENV": JSON.stringify(nodeEnv),
|
|
117
|
-
"process.env.AKAN_PUBLIC_RENDER_ENV": JSON.stringify("ssr"),
|
|
118
|
-
...Object.fromEntries(
|
|
119
|
-
Object.entries(this.#app.getPublicEnv()).map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]),
|
|
120
|
-
),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
129
|
static createPagesEntryPlugin(source: string): BunPlugin {
|
|
125
130
|
return {
|
|
126
131
|
name: "akan-pages-entry",
|
|
@@ -176,10 +181,3 @@ export class PagesBundleBuilder {
|
|
|
176
181
|
);
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
|
-
|
|
180
|
-
function loaderFor(absPath: string): "ts" | "tsx" | "js" | "jsx" {
|
|
181
|
-
if (absPath.endsWith(".tsx")) return "tsx";
|
|
182
|
-
if (absPath.endsWith(".jsx")) return "jsx";
|
|
183
|
-
if (absPath.endsWith(".ts")) return "ts";
|
|
184
|
-
return "js";
|
|
185
|
-
}
|
|
@@ -2,11 +2,19 @@ import path from "node:path";
|
|
|
2
2
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
3
3
|
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
4
4
|
|
|
5
|
+
export interface PagesEntrySourceOptions {
|
|
6
|
+
/** Detects an async default export; a caller that outlives one generation keeps its own cache across saves. */
|
|
7
|
+
isAsyncDefault?: (moduleAbsPath: string) => Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export class PagesEntrySourceGenerator {
|
|
6
11
|
#pageEntries: PageEntry[];
|
|
12
|
+
readonly #asyncDefaults = new Map<string, Promise<boolean>>();
|
|
13
|
+
readonly #detect: (moduleAbsPath: string) => Promise<boolean>;
|
|
7
14
|
|
|
8
|
-
constructor(pageEntries: PageEntry[]) {
|
|
15
|
+
constructor(pageEntries: PageEntry[], { isAsyncDefault }: PagesEntrySourceOptions = {}) {
|
|
9
16
|
this.#pageEntries = pageEntries;
|
|
17
|
+
this.#detect = isAsyncDefault ?? ((moduleAbsPath) => AsyncDefaultExportDetector.detect(moduleAbsPath));
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
static generate(pageEntries: PageEntry[]): string {
|
|
@@ -21,25 +29,67 @@ export class PagesEntrySourceGenerator {
|
|
|
21
29
|
return `export const pages = {\n${lines.join("\n")}\n};\n`;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
static async generateStatic(pageEntries: PageEntry[]): Promise<string> {
|
|
25
|
-
return await new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
32
|
+
static async generateStatic(pageEntries: PageEntry[], { fromDir }: { fromDir?: string } = {}): Promise<string> {
|
|
33
|
+
return await new PagesEntrySourceGenerator(pageEntries).generateStatic({ fromDir });
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
|
|
36
|
+
// `fromDir` makes the specifiers relative: Bun inlines an absolute import of a file that is also an entrypoint.
|
|
37
|
+
async generateStatic({ fromDir }: { fromDir?: string } = {}): Promise<string> {
|
|
29
38
|
const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
|
|
30
|
-
const specifier =
|
|
39
|
+
const specifier = fromDir
|
|
40
|
+
? PagesEntrySourceGenerator.#toRelativeSpecifier(fromDir, moduleAbsPath)
|
|
41
|
+
: PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
|
|
31
42
|
return `import * as page${index} from ${JSON.stringify(specifier)};`;
|
|
32
43
|
});
|
|
33
44
|
const entries = await Promise.all(
|
|
34
45
|
this.#pageEntries.map(async ({ key, moduleAbsPath }, index) => {
|
|
35
|
-
const isAsyncDefault = await
|
|
46
|
+
const isAsyncDefault = await this.#isAsyncDefault(moduleAbsPath);
|
|
36
47
|
return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
|
|
37
48
|
}),
|
|
38
49
|
);
|
|
39
50
|
return `${imports.join("\n")}\nexport const pages = {\n${entries.join("\n")}\n};\n`;
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
// Route HMR in the dev registry bundle: `akanWebkit` is the entry's namespace import of `akanjs/webkit`.
|
|
54
|
+
async generateHotReplace({
|
|
55
|
+
fromDir,
|
|
56
|
+
ownerId,
|
|
57
|
+
moduleIds,
|
|
58
|
+
}: {
|
|
59
|
+
fromDir: string;
|
|
60
|
+
ownerId: string;
|
|
61
|
+
moduleIds: string[];
|
|
62
|
+
}): Promise<string> {
|
|
63
|
+
const entries = await Promise.all(
|
|
64
|
+
this.#pageEntries.map(async ({ key, moduleAbsPath }) => {
|
|
65
|
+
const specifier = PagesEntrySourceGenerator.#toRelativeSpecifier(fromDir, moduleAbsPath);
|
|
66
|
+
const isAsyncDefault = await this.#isAsyncDefault(moduleAbsPath);
|
|
67
|
+
return ` ${JSON.stringify(key)}: { loader: async () => require(${JSON.stringify(specifier)}), isAsyncDefault: ${isAsyncDefault} },`;
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
return `__akan.accept(${JSON.stringify(ownerId)}, ${JSON.stringify(moduleIds)}, async () => {
|
|
71
|
+
const replaced = await akanWebkit.replacePages({
|
|
72
|
+
${entries.join("\n")}
|
|
73
|
+
});
|
|
74
|
+
if (!replaced) throw new Error("the route table no longer matches the pages it was built from");
|
|
75
|
+
});
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#isAsyncDefault(moduleAbsPath: string): Promise<boolean> {
|
|
80
|
+
const known = this.#asyncDefaults.get(moduleAbsPath);
|
|
81
|
+
if (known) return known;
|
|
82
|
+
const detected = this.#detect(moduleAbsPath);
|
|
83
|
+
this.#asyncDefaults.set(moduleAbsPath, detected);
|
|
84
|
+
return detected;
|
|
85
|
+
}
|
|
86
|
+
|
|
42
87
|
static #toImportSpecifier(moduleAbsPath: string): string {
|
|
43
88
|
return path.resolve(moduleAbsPath).split(path.sep).join("/");
|
|
44
89
|
}
|
|
90
|
+
|
|
91
|
+
static #toRelativeSpecifier(fromDir: string, moduleAbsPath: string): string {
|
|
92
|
+
const relative = path.relative(fromDir, path.resolve(moduleAbsPath)).split(path.sep).join("/");
|
|
93
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
94
|
+
}
|
|
45
95
|
}
|