@akanjs/devkit 1.0.20 → 2.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.ko.md +65 -0
  2. package/README.md +62 -6
  3. package/aiEditor.ts +304 -0
  4. package/akanApp/akanApp.host.ts +393 -0
  5. package/akanApp/index.ts +1 -0
  6. package/akanConfig/akanConfig.test.ts +236 -0
  7. package/akanConfig/akanConfig.ts +384 -0
  8. package/akanConfig/index.ts +2 -0
  9. package/akanConfig/types.ts +23 -0
  10. package/applicationBuildReporter.ts +69 -0
  11. package/applicationBuildRunner.ts +302 -0
  12. package/applicationReleasePackager.ts +206 -0
  13. package/artifact/implicitRootLayout.ts +155 -0
  14. package/artifact/index.ts +1 -0
  15. package/artifact/routeSeedIndex.test.ts +98 -0
  16. package/artifact/routeSeedIndex.ts +130 -0
  17. package/auth.ts +41 -0
  18. package/builder.ts +164 -0
  19. package/capacitor.base.config.ts +88 -0
  20. package/capacitorApp.ts +440 -0
  21. package/commandDecorators/argMeta.ts +102 -0
  22. package/commandDecorators/command.ts +351 -0
  23. package/commandDecorators/commandBuilder.ts +224 -0
  24. package/commandDecorators/commandDecorators.test.ts +212 -0
  25. package/commandDecorators/commandMeta.ts +7 -0
  26. package/commandDecorators/dependencyBuilder.ts +100 -0
  27. package/{esm/src/commandDecorators/helpFormatter.js → commandDecorators/helpFormatter.ts} +100 -47
  28. package/{esm/src/commandDecorators/index.js → commandDecorators/index.ts} +4 -2
  29. package/commandDecorators/targetMeta.ts +31 -0
  30. package/commandDecorators/types.ts +10 -0
  31. package/constants.ts +25 -0
  32. package/createTunnel.ts +36 -0
  33. package/dependencyScanner.ts +357 -0
  34. package/devkitUtils.test.ts +259 -0
  35. package/executors.test.ts +315 -0
  36. package/executors.ts +1390 -0
  37. package/{esm/src/extractDeps.js → extractDeps.ts} +26 -20
  38. package/{esm/src/fileEditor.js → fileEditor.ts} +51 -32
  39. package/fileSys.ts +39 -0
  40. package/frontendBuild/allRoutesBuilder.ts +103 -0
  41. package/frontendBuild/buildRouteClient.test.ts +190 -0
  42. package/frontendBuild/clientBuildTypes.ts +114 -0
  43. package/frontendBuild/clientEntriesBundler.ts +303 -0
  44. package/frontendBuild/clientEntryDiscovery.ts +199 -0
  45. package/frontendBuild/csrArtifactBuilder.ts +237 -0
  46. package/frontendBuild/cssCompiler.ts +286 -0
  47. package/frontendBuild/cssImportResolver.ts +116 -0
  48. package/frontendBuild/fontOptimizer.ts +427 -0
  49. package/frontendBuild/frontendBuild.test.ts +204 -0
  50. package/frontendBuild/hmrChangeClassifier.ts +28 -0
  51. package/frontendBuild/hmrWatcher.ts +102 -0
  52. package/frontendBuild/index.ts +18 -0
  53. package/frontendBuild/pagesBundleBuilder.ts +137 -0
  54. package/frontendBuild/pagesEntrySourceGenerator.ts +37 -0
  55. package/frontendBuild/precompressArtifacts.ts +59 -0
  56. package/frontendBuild/routeClientBuilder.ts +290 -0
  57. package/frontendBuild/routesManifestArtifactSerializer.ts +62 -0
  58. package/frontendBuild/ssrBaseArtifactBuilder.ts +139 -0
  59. package/frontendBuild/vendorSpecifiers.ts +16 -0
  60. package/frontendBuild/watchRootResolver.ts +28 -0
  61. package/getCredentials.ts +19 -0
  62. package/getDirname.ts +3 -0
  63. package/getModelFileData.ts +59 -0
  64. package/getRelatedCnsts.ts +313 -0
  65. package/guideline.ts +19 -0
  66. package/incrementalBuilder/incrementalBuilder.host.test.ts +51 -0
  67. package/incrementalBuilder/incrementalBuilder.host.ts +152 -0
  68. package/incrementalBuilder/incrementalBuilder.proc.ts +331 -0
  69. package/incrementalBuilder/index.ts +1 -0
  70. package/{esm/src/index.js → index.ts} +28 -15
  71. package/lint/no-deep-internal-import.grit +25 -0
  72. package/lint/no-import-client-functions.grit +32 -0
  73. package/lint/no-import-external-library.grit +21 -0
  74. package/lint/no-js-private-class-method.grit +42 -0
  75. package/lint/no-use-client-in-server.grit +7 -0
  76. package/lint/non-scalar-props-restricted.grit +13 -0
  77. package/linter.ts +271 -0
  78. package/mobile/index.ts +1 -0
  79. package/mobile/mobileTarget.test.ts +53 -0
  80. package/mobile/mobileTarget.ts +88 -0
  81. package/package.json +48 -31
  82. package/prompter.ts +72 -0
  83. package/scanInfo.ts +606 -0
  84. package/selectModel.ts +11 -0
  85. package/{esm/src/spinner.js → spinner.ts} +22 -28
  86. package/{esm/src/capacitorApp.js → src/capacitorApp.ts} +82 -81
  87. package/sshTunnel.ts +152 -0
  88. package/{esm/src/streamAi.js → streamAi.ts} +18 -12
  89. package/transforms/barrelAnalyzer.ts +278 -0
  90. package/transforms/barrelImportsPlugin.ts +504 -0
  91. package/transforms/externalizeFrameworkPlugin.ts +185 -0
  92. package/transforms/index.ts +5 -0
  93. package/transforms/rscUseClientTransform.ts +59 -0
  94. package/transforms/transforms.test.ts +208 -0
  95. package/transforms/useClientBundlePlugin.ts +47 -0
  96. package/tsconfig.json +37 -0
  97. package/typeChecker.ts +264 -0
  98. package/types.ts +44 -0
  99. package/ui/MultiScrollList.tsx +242 -0
  100. package/ui/ScrollList.tsx +107 -0
  101. package/ui/index.ts +2 -0
  102. package/{esm/src/uploadRelease.js → uploadRelease.ts} +50 -34
  103. package/{esm/src/useStdoutDimensions.js → useStdoutDimensions.ts} +5 -5
  104. package/cjs/index.js +0 -21
  105. package/cjs/src/aiEditor.js +0 -311
  106. package/cjs/src/auth.js +0 -72
  107. package/cjs/src/builder.js +0 -114
  108. package/cjs/src/capacitorApp.js +0 -313
  109. package/cjs/src/commandDecorators/argMeta.js +0 -88
  110. package/cjs/src/commandDecorators/command.js +0 -324
  111. package/cjs/src/commandDecorators/commandMeta.js +0 -30
  112. package/cjs/src/commandDecorators/helpFormatter.js +0 -211
  113. package/cjs/src/commandDecorators/index.js +0 -31
  114. package/cjs/src/commandDecorators/targetMeta.js +0 -57
  115. package/cjs/src/commandDecorators/types.js +0 -15
  116. package/cjs/src/constants.js +0 -46
  117. package/cjs/src/createTunnel.js +0 -49
  118. package/cjs/src/dependencyScanner.js +0 -220
  119. package/cjs/src/executors.js +0 -964
  120. package/cjs/src/extractDeps.js +0 -103
  121. package/cjs/src/fileEditor.js +0 -120
  122. package/cjs/src/getCredentials.js +0 -44
  123. package/cjs/src/getDirname.js +0 -38
  124. package/cjs/src/getModelFileData.js +0 -66
  125. package/cjs/src/getRelatedCnsts.js +0 -260
  126. package/cjs/src/guideline.js +0 -15
  127. package/cjs/src/index.js +0 -65
  128. package/cjs/src/linter.js +0 -238
  129. package/cjs/src/prompter.js +0 -85
  130. package/cjs/src/scanInfo.js +0 -491
  131. package/cjs/src/selectModel.js +0 -46
  132. package/cjs/src/spinner.js +0 -93
  133. package/cjs/src/streamAi.js +0 -62
  134. package/cjs/src/typeChecker.js +0 -207
  135. package/cjs/src/types.js +0 -15
  136. package/cjs/src/uploadRelease.js +0 -112
  137. package/cjs/src/useStdoutDimensions.js +0 -43
  138. package/esm/index.js +0 -1
  139. package/esm/src/aiEditor.js +0 -282
  140. package/esm/src/auth.js +0 -42
  141. package/esm/src/builder.js +0 -81
  142. package/esm/src/commandDecorators/argMeta.js +0 -54
  143. package/esm/src/commandDecorators/command.js +0 -290
  144. package/esm/src/commandDecorators/commandMeta.js +0 -7
  145. package/esm/src/commandDecorators/targetMeta.js +0 -33
  146. package/esm/src/commandDecorators/types.js +0 -0
  147. package/esm/src/constants.js +0 -17
  148. package/esm/src/createTunnel.js +0 -26
  149. package/esm/src/dependencyScanner.js +0 -187
  150. package/esm/src/executors.js +0 -928
  151. package/esm/src/getCredentials.js +0 -11
  152. package/esm/src/getDirname.js +0 -5
  153. package/esm/src/getModelFileData.js +0 -33
  154. package/esm/src/getRelatedCnsts.js +0 -221
  155. package/esm/src/guideline.js +0 -0
  156. package/esm/src/linter.js +0 -205
  157. package/esm/src/prompter.js +0 -51
  158. package/esm/src/scanInfo.js +0 -455
  159. package/esm/src/selectModel.js +0 -13
  160. package/esm/src/typeChecker.js +0 -174
  161. package/esm/src/types.js +0 -0
  162. package/index.d.ts +0 -1
  163. package/src/aiEditor.d.ts +0 -50
  164. package/src/auth.d.ts +0 -9
  165. package/src/builder.d.ts +0 -18
  166. package/src/capacitorApp.d.ts +0 -39
  167. package/src/commandDecorators/argMeta.d.ts +0 -67
  168. package/src/commandDecorators/command.d.ts +0 -2
  169. package/src/commandDecorators/commandMeta.d.ts +0 -2
  170. package/src/commandDecorators/helpFormatter.d.ts +0 -3
  171. package/src/commandDecorators/index.d.ts +0 -6
  172. package/src/commandDecorators/targetMeta.d.ts +0 -19
  173. package/src/commandDecorators/types.d.ts +0 -1
  174. package/src/constants.d.ts +0 -26
  175. package/src/createTunnel.d.ts +0 -8
  176. package/src/dependencyScanner.d.ts +0 -23
  177. package/src/executors.d.ts +0 -296
  178. package/src/extractDeps.d.ts +0 -7
  179. package/src/fileEditor.d.ts +0 -16
  180. package/src/getCredentials.d.ts +0 -12
  181. package/src/getDirname.d.ts +0 -1
  182. package/src/getModelFileData.d.ts +0 -16
  183. package/src/getRelatedCnsts.d.ts +0 -53
  184. package/src/guideline.d.ts +0 -19
  185. package/src/index.d.ts +0 -23
  186. package/src/linter.d.ts +0 -109
  187. package/src/prompter.d.ts +0 -14
  188. package/src/scanInfo.d.ts +0 -82
  189. package/src/selectModel.d.ts +0 -1
  190. package/src/spinner.d.ts +0 -20
  191. package/src/streamAi.d.ts +0 -6
  192. package/src/typeChecker.d.ts +0 -52
  193. package/src/types.d.ts +0 -31
  194. package/src/uploadRelease.d.ts +0 -10
  195. package/src/useStdoutDimensions.d.ts +0 -1
@@ -0,0 +1,504 @@
1
+ import path from "node:path";
2
+ import type { BunPlugin } from "bun";
3
+ import ts from "typescript";
4
+ import type { App } from "../commandDecorators";
5
+ import { BarrelAnalyzer, type BarrelExportMap, type PackageEntry } from "./barrelAnalyzer";
6
+
7
+ export interface BarrelImportsPluginOptions {
8
+ /** Absolute paths whose content should be returned unchanged (e.g. node_modules). */
9
+ skipPath?: (absPath: string) => boolean;
10
+ /**
11
+ * Optional transform applied after the barrel rewrite in the same onLoad
12
+ * pass. Useful to chain further transforms (e.g. `"use client"` stubbing)
13
+ * that would otherwise be blocked — Bun's `onLoad` cannot fall through to a
14
+ * second plugin once a response is returned.
15
+ * Return the transformed source, or `null` to indicate no change.
16
+ */
17
+ pipeAfter?: (source: string, args: { path: string }) => string | Promise<string | null> | null;
18
+ }
19
+
20
+ export const createBarrelImportsPlugin = async (
21
+ app: App,
22
+ { skipPath = defaultSkipPath, pipeAfter }: BarrelImportsPluginOptions = {},
23
+ ): Promise<BunPlugin> => {
24
+ const akanConfig = await app.getConfig();
25
+ const barrels = [...new Set(akanConfig.barrelImports)].filter(Boolean);
26
+ const analyzer = new BarrelAnalyzer({
27
+ resolvePackage: await createTsconfigPackageResolver(app),
28
+ });
29
+
30
+ return {
31
+ name: "barrel-imports",
32
+ setup(build) {
33
+ // Exclude third-party node_modules, but keep node_modules/akanjs in generated
34
+ // workspaces so framework `"use client"` modules are still stubbed for RSC.
35
+ //
36
+ // The optional `(\?v=\d+)?` tail lets the filter match paths that HMR
37
+ // has version-tagged (`./_index.tsx?v=3`). We strip the query before
38
+ // reading and normalize it away from the path we pass downstream so
39
+ // only the cache-bust `pipeAfter` step (if any) knows about it.
40
+ build.onLoad(
41
+ {
42
+ filter:
43
+ /^(?:(?!.*[\\/]node_modules[\\/]).*|.*[\\/]node_modules[\\/]akanjs[\\/].*)\.(tsx|ts|jsx|js)(\?v=\d+)?$/,
44
+ },
45
+ async (args) => {
46
+ const realPath = args.path.replace(/\?v=\d+$/, "");
47
+ const loader = loaderFor(realPath);
48
+ if (skipPath(realPath)) {
49
+ const raw = await Bun.file(realPath).text();
50
+ return { contents: raw, loader };
51
+ }
52
+
53
+ let source = await Bun.file(realPath).text();
54
+
55
+ // Bun's macro evaluator has a race condition when the plugin returns
56
+ // rewritten source for a module that also contains
57
+ // `with { type: "macro" }` imports *and* another macro-host file is
58
+ // being evaluated concurrently in the same graph: one of the macro
59
+ // identifiers ends up undefined at runtime (e.g. `getSerializedSignal
60
+ // is not defined`). Returning the unmodified source for macro hosts
61
+ // sidesteps the race. The trade-off is that a handful of `useClient`-
62
+ // style files keep their original barrel imports; the rest of the
63
+ // tree still benefits from flattening.
64
+ const hasMacroAttr = MACRO_ATTR_RE.test(source);
65
+
66
+ if (!hasMacroAttr && barrels.length > 0) {
67
+ // Fast pre-check to avoid tokenizing every file in the workspace.
68
+ let maybe = false;
69
+ for (const b of barrels) {
70
+ if (source.includes(b)) {
71
+ maybe = true;
72
+ break;
73
+ }
74
+ }
75
+ if (maybe) {
76
+ const rewritten = await rewriteBarrelImports(source, barrels, analyzer);
77
+ if (rewritten !== null) source = rewritten;
78
+ }
79
+ }
80
+
81
+ if (pipeAfter) {
82
+ const piped = await pipeAfter(source, { path: realPath });
83
+ if (piped !== null) source = piped;
84
+ }
85
+
86
+ return { contents: source, loader };
87
+ },
88
+ );
89
+ },
90
+ };
91
+ };
92
+
93
+ /**
94
+ * Build a `resolvePackage` that maps a package specifier (like `akanjs/ui`)
95
+ * to its barrel entry file using the workspace tsconfig `paths`. If no direct
96
+ * mapping exists, falls back to node_modules resolution.
97
+ */
98
+ export const createTsconfigPackageResolver = async (
99
+ app: App,
100
+ ): Promise<(pkgName: string) => Promise<PackageEntry | null>> => {
101
+ const tsconfig = await app.getTsConfig();
102
+ const tsconfigPaths = tsconfig.compilerOptions.paths ?? {};
103
+ // Pre-compute wildcard entries so we don't walk the full map per lookup.
104
+ // Longer prefixes sort first so `@libs/util/*` wins over `@libs/*`.
105
+ const wildcardEntries = Object.entries(tsconfigPaths)
106
+ .filter(([k]) => k.endsWith("/*"))
107
+ .map(([k, v]) => ({
108
+ prefix: k.slice(0, -1), // keep trailing `/`
109
+ replacements: v,
110
+ }))
111
+ .sort((a, b) => b.prefix.length - a.prefix.length);
112
+
113
+ return async (pkgName) => {
114
+ const exact = tsconfigPaths[pkgName];
115
+ if (exact && exact.length > 0) {
116
+ const raw = exact[0];
117
+ if (!raw) return null;
118
+ const entryFile = path.resolve(app.workspace.workspaceRoot, raw);
119
+ if (!(await Bun.file(entryFile).exists())) return null;
120
+ // Detect "facet" barrels: specifiers like `@libs/util/server` whose entry
121
+ // file is a sibling inside the parent directory (`libs/util/server.ts`)
122
+ // rather than an `index.*` inside a dedicated package directory
123
+ // (`libs/util/server/index.ts`). For facets, the subpath the analyzer
124
+ // generates for a leaf like `libs/util/lib/sig.ts` must be computed
125
+ // against the parent package (`@libs/util`) so that the rewritten
126
+ // import resolves via the workspace's `@libs/*` tsconfig wildcard
127
+ // (`libs/util/lib/sig`). Using the raw `pkgName` (`@libs/util/server`)
128
+ // would generate `@libs/util/server/lib/sig`, a path that does not exist
129
+ // on disk and cannot be imported.
130
+ const parsed = path.parse(entryFile);
131
+ const lastSlash = pkgName.lastIndexOf("/");
132
+ if (parsed.name !== "index" && lastSlash !== -1) {
133
+ const facet = pkgName.slice(lastSlash + 1);
134
+ const parentSpec = pkgName.slice(0, lastSlash);
135
+ if (facet === parsed.name && parentSpec.length > 0) {
136
+ return { pkgName: parentSpec, entryFile, pkgDir: parsed.dir };
137
+ }
138
+ }
139
+ return { pkgName, entryFile, pkgDir: path.dirname(entryFile) };
140
+ }
141
+
142
+ // Wildcard fallback: tsconfig entries like `@libs/*` → `./libs/*` map a
143
+ // whole family of specifiers to workspace directories. Without this,
144
+ // barrels such as `@libs/util/ui` or `@apps/minimal/client` never resolve
145
+ // to an entry file — the analyzer returns `null`, the plugin skips them,
146
+ // and the consumer falls back to Bun's default resolution which loads the
147
+ // full barrel (pulling the entire transitive macro / side-effect graph).
148
+ for (const { prefix, replacements } of wildcardEntries) {
149
+ if (!pkgName.startsWith(prefix)) continue;
150
+ const suffix = pkgName.slice(prefix.length);
151
+ for (const repl of replacements) {
152
+ if (!repl) continue;
153
+ const replPath = repl.endsWith("/*") ? repl.slice(0, -1) : repl;
154
+ const candidate = path.resolve(app.workspace.workspaceRoot, replPath + suffix);
155
+ // Try `candidate.<ext>` first (facet-barrel: sibling file like
156
+ // `apps/minimal/client.ts`). If it exists we treat the PARENT directory
157
+ // as `pkgDir` and the PARENT specifier (`@apps/minimal`) as `pkgName`
158
+ // so a leaf at `apps/minimal/lib/useClient.ts` rewrites to
159
+ // `@apps/minimal/lib/useClient` and resolves via the same `@apps/*`
160
+ // wildcard. Using the raw `pkgName` would yield
161
+ // `@apps/minimal/client/lib/useClient`, a path that does not exist.
162
+ for (const ext of CANDIDATE_EXTS) {
163
+ const file = `${candidate}${ext}`;
164
+ if (await Bun.file(file).exists()) {
165
+ const lastSlash = pkgName.lastIndexOf("/");
166
+ if (lastSlash !== -1) {
167
+ const parentSpec = pkgName.slice(0, lastSlash);
168
+ if (parentSpec.length > 0) {
169
+ return { pkgName: parentSpec, entryFile: file, pkgDir: path.dirname(file) };
170
+ }
171
+ }
172
+ return { pkgName, entryFile: file, pkgDir: path.dirname(file) };
173
+ }
174
+ }
175
+ for (const ext of CANDIDATE_EXTS) {
176
+ const file = path.join(candidate, `index${ext}`);
177
+ if (await Bun.file(file).exists()) {
178
+ return { pkgName, entryFile: file, pkgDir: candidate };
179
+ }
180
+ }
181
+ }
182
+ // A prefix matched but nothing on disk — stop so a shorter, less specific
183
+ // prefix doesn't accidentally resolve to an unrelated location.
184
+ return null;
185
+ }
186
+
187
+ // Fallback: resolve package exports from node_modules. This supports
188
+ // single-package subpaths such as `akanjs/ui`, whose package.json lives at
189
+ // node_modules/akanjs/package.json rather than node_modules/akanjs/ui.
190
+ const exported = await resolveNodePackageExport(app.workspace.workspaceRoot, pkgName);
191
+ if (exported) return exported;
192
+
193
+ const pkgJsonPath = path.join(app.workspace.workspaceRoot, "node_modules", pkgName, "package.json");
194
+ if (!(await Bun.file(pkgJsonPath).exists())) return null;
195
+ try {
196
+ const pkgJson = JSON.parse(await Bun.file(pkgJsonPath).text()) as {
197
+ main?: string;
198
+ module?: string;
199
+ };
200
+ const rel = pkgJson.module ?? pkgJson.main ?? "index.js";
201
+ const entryFile = path.resolve(path.dirname(pkgJsonPath), rel);
202
+ if (!(await Bun.file(entryFile).exists())) return null;
203
+ return { pkgName, entryFile, pkgDir: path.dirname(pkgJsonPath) };
204
+ } catch {
205
+ return null;
206
+ }
207
+ };
208
+ };
209
+
210
+ const CANDIDATE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
211
+
212
+ const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
213
+ const AKANJS_NODE_MODULE_RE = /[\\/]node_modules[\\/]akanjs[\\/]/;
214
+ const defaultSkipPath = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
215
+
216
+ type ExportValue = string | string[] | { [condition: string]: ExportValue | undefined };
217
+
218
+ const resolveNodePackageExport = async (workspaceRoot: string, specifier: string): Promise<PackageEntry | null> => {
219
+ const packageName = getPackageName(specifier);
220
+ if (!packageName) return null;
221
+ const pkgJsonPath = path.join(workspaceRoot, "node_modules", packageName, "package.json");
222
+ if (!(await Bun.file(pkgJsonPath).exists())) return null;
223
+
224
+ try {
225
+ const pkgDir = path.dirname(pkgJsonPath);
226
+ const pkgJson = JSON.parse(await Bun.file(pkgJsonPath).text()) as {
227
+ exports?: Record<string, ExportValue>;
228
+ module?: string;
229
+ main?: string;
230
+ };
231
+ const subpath = specifier === packageName ? "." : `.${specifier.slice(packageName.length)}`;
232
+ const exported = resolvePackageExport(pkgJson.exports, subpath);
233
+ const rel = exported ?? (subpath === "." ? (pkgJson.module ?? pkgJson.main ?? "index.js") : null);
234
+ if (!rel || !rel.startsWith(".")) return null;
235
+ const entryFile = await resolveFileCandidate(path.resolve(pkgDir, rel));
236
+ if (!entryFile) return null;
237
+ const pkgEntryName = specifier;
238
+ return { pkgName: pkgEntryName, entryFile, pkgDir: path.dirname(entryFile), preserveFilePath: true };
239
+ } catch {
240
+ return null;
241
+ }
242
+ };
243
+
244
+ const resolveExportValue = (value: ExportValue | undefined): string | null => {
245
+ if (!value) return null;
246
+ if (typeof value === "string") return value;
247
+ if (Array.isArray(value)) {
248
+ for (const item of value) {
249
+ const resolved = resolveExportValue(item);
250
+ if (resolved) return resolved;
251
+ }
252
+ return null;
253
+ }
254
+ for (const condition of ["source", "import", "default", "types"]) {
255
+ const resolved = resolveExportValue(value[condition]);
256
+ if (resolved) return resolved;
257
+ }
258
+ return null;
259
+ };
260
+
261
+ const resolvePackageExport = (exportsMap: Record<string, ExportValue> | undefined, subpath: string): string | null => {
262
+ if (!exportsMap) return null;
263
+ const exact = resolveExportValue(exportsMap[subpath]);
264
+ if (exact) return exact;
265
+
266
+ for (const [key, value] of Object.entries(exportsMap)) {
267
+ const starIdx = key.indexOf("*");
268
+ if (starIdx === -1) continue;
269
+ const prefix = key.slice(0, starIdx);
270
+ const suffix = key.slice(starIdx + 1);
271
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
272
+ const wildcard = subpath.slice(prefix.length, subpath.length - suffix.length);
273
+ const resolved = resolveExportValue(value);
274
+ if (resolved) return resolved.replace("*", wildcard);
275
+ }
276
+
277
+ return null;
278
+ };
279
+
280
+ const getPackageName = (specifier: string): string | null => {
281
+ const parts = specifier.split("/");
282
+ if (!parts[0]) return null;
283
+ if (specifier.startsWith("@")) return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
284
+ return parts[0];
285
+ };
286
+
287
+ const resolveFileCandidate = async (candidate: string): Promise<string | null> => {
288
+ if (await Bun.file(candidate).exists()) return candidate;
289
+ if (path.extname(candidate)) return null;
290
+ for (const ext of CANDIDATE_EXTS) {
291
+ const file = `${candidate}${ext}`;
292
+ if (await Bun.file(file).exists()) return file;
293
+ }
294
+ for (const ext of CANDIDATE_EXTS) {
295
+ const file = path.join(candidate, `index${ext}`);
296
+ if (await Bun.file(file).exists()) return file;
297
+ }
298
+ return null;
299
+ };
300
+
301
+ // Matches `with { type: "macro" }` import attributes (single or double quotes,
302
+ // tolerant of whitespace). Used to detect macro-host modules so the plugin
303
+ // can leave their source untouched.
304
+ const MACRO_ATTR_RE = /with\s*\{\s*type\s*:\s*["']macro["']\s*\}/;
305
+
306
+ /** Exposed for testing. */
307
+ export const rewriteBarrelImports = async (
308
+ source: string,
309
+ barrels: string[],
310
+ analyzer: BarrelAnalyzer,
311
+ ): Promise<string | null> => {
312
+ const statements = findImportStatements(source);
313
+ if (statements.length === 0) return null;
314
+
315
+ // Walk import statements in reverse so replacements don't shift earlier ranges.
316
+ let changed = false;
317
+ let out = source;
318
+ for (let i = statements.length - 1; i >= 0; i--) {
319
+ const stmt = statements[i];
320
+ if (!stmt) continue;
321
+ if (!barrels.includes(stmt.specifier)) continue;
322
+ const map = await analyzer.analyze(stmt.specifier);
323
+ if (!map || map.size === 0) continue;
324
+ const replacement = rewriteSingleStatement(stmt, map);
325
+ if (replacement === null) continue;
326
+ out = out.slice(0, stmt.start) + replacement + out.slice(stmt.end);
327
+ changed = true;
328
+ }
329
+ return changed ? out : null;
330
+ };
331
+
332
+ interface ImportStatement {
333
+ start: number;
334
+ end: number;
335
+ clause: string;
336
+ specifier: string;
337
+ trailingSemicolon: boolean;
338
+ raw: string;
339
+ }
340
+
341
+ const findImportStatements = (source: string): ImportStatement[] => {
342
+ const statements: ImportStatement[] = [];
343
+ const sourceFile = ts.createSourceFile("barrel-imports.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
344
+ for (const statement of sourceFile.statements) {
345
+ if (!ts.isImportDeclaration(statement)) continue;
346
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
347
+ const importClause = statement.importClause;
348
+ if (!importClause) continue;
349
+ const statementStart = statement.getStart(sourceFile);
350
+ const statementEnd = statement.end;
351
+ statements.push({
352
+ start: statementStart,
353
+ end: statementEnd,
354
+ clause: source.slice(importClause.getStart(sourceFile), importClause.end).trim(),
355
+ specifier: statement.moduleSpecifier.text,
356
+ trailingSemicolon: source.slice(statement.moduleSpecifier.end, statement.end).includes(";"),
357
+ raw: source.slice(statementStart, statementEnd),
358
+ });
359
+ }
360
+ return statements;
361
+ };
362
+
363
+ interface NamedImportItem {
364
+ imported: string;
365
+ local: string;
366
+ isType: boolean;
367
+ }
368
+
369
+ interface ParsedClause {
370
+ defaultImport?: string;
371
+ namespaceImport?: string;
372
+ named?: NamedImportItem[];
373
+ /** Whole clause is `import type { ... } from "..."`. */
374
+ typeOnly: boolean;
375
+ }
376
+
377
+ const parseImportClause = (clause: string): ParsedClause | null => {
378
+ let rest = clause.trim();
379
+ let typeOnly = false;
380
+ if (rest.startsWith("type ")) {
381
+ typeOnly = true;
382
+ rest = rest.slice(5).trim();
383
+ }
384
+ const parsed: ParsedClause = { typeOnly };
385
+ // Try pattern: default + rest
386
+ const commaMatch = /^(\w+)\s*,\s*(.+)$/.exec(rest);
387
+ if (commaMatch) {
388
+ parsed.defaultImport = commaMatch[1];
389
+ rest = commaMatch[2] ?? "";
390
+ } else if (/^\w+$/.test(rest)) {
391
+ parsed.defaultImport = rest;
392
+ return parsed;
393
+ }
394
+ if (rest.startsWith("*")) {
395
+ const ns = /^\*\s+as\s+(\w+)$/.exec(rest);
396
+ if (!ns) return null;
397
+ parsed.namespaceImport = ns[1];
398
+ return parsed;
399
+ }
400
+ if (rest.startsWith("{")) {
401
+ const close = rest.indexOf("}");
402
+ if (close === -1) return null;
403
+ const inner = rest.slice(1, close);
404
+ parsed.named = parseNamedImportList(inner);
405
+ return parsed;
406
+ }
407
+ return parsed;
408
+ };
409
+
410
+ const parseNamedImportList = (body: string): NamedImportItem[] => {
411
+ const out: NamedImportItem[] = [];
412
+ for (const raw of body.split(",")) {
413
+ const s = raw.trim();
414
+ if (!s) continue;
415
+ let isType = false;
416
+ let rest = s;
417
+ if (rest.startsWith("type ")) {
418
+ isType = true;
419
+ rest = rest.slice(5).trim();
420
+ }
421
+ const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(rest);
422
+ if (asMatch) {
423
+ out.push({ imported: asMatch[1] ?? "", local: asMatch[2] ?? "", isType });
424
+ continue;
425
+ }
426
+ if (/^\w+$/.test(rest)) {
427
+ out.push({ imported: rest, local: rest, isType });
428
+ }
429
+ }
430
+ return out;
431
+ };
432
+
433
+ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): string | null => {
434
+ const clause = parseImportClause(stmt.clause);
435
+ if (!clause) return null;
436
+ // Namespace imports need the whole barrel — cannot rewrite safely.
437
+ if (clause.namespaceImport) return null;
438
+ // Pure type imports are erased at build; leave them alone.
439
+ if (clause.typeOnly && !clause.defaultImport) return null;
440
+ if (!clause.named || clause.named.length === 0) {
441
+ // Only default import — nothing to split.
442
+ return null;
443
+ }
444
+
445
+ const remaining: NamedImportItem[] = [];
446
+ const rewrites = new Map<string, NamedImportItem[]>();
447
+ for (const item of clause.named) {
448
+ if (item.isType) {
449
+ remaining.push(item);
450
+ continue;
451
+ }
452
+ const target = map.get(item.imported);
453
+ if (!target) {
454
+ remaining.push(item);
455
+ continue;
456
+ }
457
+ const list = rewrites.get(target.subpath) ?? [];
458
+ // Use the leaf's original name, keeping the consumer's local alias.
459
+ list.push({ imported: target.originalName, local: item.local, isType: false });
460
+ rewrites.set(target.subpath, list);
461
+ }
462
+
463
+ if (rewrites.size === 0) return null;
464
+
465
+ const lines: string[] = [];
466
+ // Always emit trailing semicolons; safe even when the source omitted them.
467
+ const tail = ";";
468
+
469
+ if (shouldPreserveBarrelSideEffects(stmt.specifier)) {
470
+ lines.push(`import "${stmt.specifier}"${tail}`);
471
+ }
472
+
473
+ // Re-emit an import from the original barrel that carries whatever we could
474
+ // not flatten (default import, type-only items, unknown names).
475
+ if (clause.defaultImport || remaining.length > 0) {
476
+ const parts: string[] = [];
477
+ if (clause.defaultImport) parts.push(clause.defaultImport);
478
+ if (remaining.length > 0) {
479
+ parts.push(`{ ${remaining.map(serializeNamedItem).join(", ")} }`);
480
+ }
481
+ lines.push(`import ${parts.join(", ")} from "${stmt.specifier}"${tail}`);
482
+ }
483
+
484
+ for (const [subpath, items] of rewrites) {
485
+ lines.push(`import { ${items.map(serializeNamedItem).join(", ")} } from "${subpath}"${tail}`);
486
+ }
487
+
488
+ return lines.join("\n");
489
+ };
490
+
491
+ const shouldPreserveBarrelSideEffects = (specifier: string): boolean => /^@(apps|libs)\/[^/]+\/client$/.test(specifier);
492
+
493
+ const serializeNamedItem = (item: NamedImportItem): string => {
494
+ const prefix = item.isType ? "type " : "";
495
+ if (item.imported === item.local) return `${prefix}${item.imported}`;
496
+ return `${prefix}${item.imported} as ${item.local}`;
497
+ };
498
+
499
+ const loaderFor = (absPath: string): "ts" | "tsx" | "js" | "jsx" => {
500
+ if (absPath.endsWith(".tsx")) return "tsx";
501
+ if (absPath.endsWith(".jsx")) return "jsx";
502
+ if (absPath.endsWith(".ts")) return "ts";
503
+ return "js";
504
+ };
@@ -0,0 +1,185 @@
1
+ import path from "node:path";
2
+ import type { BunPlugin } from "bun";
3
+ import type { App } from "../commandDecorators";
4
+
5
+ /**
6
+ * Keep framework/runtime singletons external while bundling authored
7
+ * workspace sources and ordinary npm dependencies into the pages bundle.
8
+ *
9
+ * Why a plugin instead of `Bun.build({ external })` or
10
+ * `Bun.build({ packages: "external" })`:
11
+ * - `packages: "external"` externalizes every bare specifier, including
12
+ * `@apps/*` / `@libs/*` — workspace packages that must stay bundled
13
+ * so the `"use client"` plugin can rewrite their exports — and ordinary
14
+ * npm dependencies like `dayjs` / `clsx`, which the production runtime
15
+ * package.json does not install for the SSR pages artifact.
16
+ * - Top-level `external: [...]` applies to macro-time module resolution
17
+ * too; any `with { type: "macro" }` import chain that transitively
18
+ * resolves `react` during build then fails with `ENOENT "react"`.
19
+ *
20
+ * An `onResolve` plugin only participates in the output-graph walk, so
21
+ * macros evaluate against the real modules in node_modules while the
22
+ * emitted bundle keeps bare specifiers only for runtime singletons that
23
+ * are installed in the generated production package.json.
24
+ *
25
+ * Externalization rules (applied in order):
26
+ * 1. Relative specifiers (`./`, `../`) are NEVER externalized — they
27
+ * always resolve to the current package's source tree and must be
28
+ * inlined so transitive `"use client"` stubs work.
29
+ * 2. Specifiers listed in `include` (workspace aliases like
30
+ * `@apps/*`, `@libs/*`) are NEVER externalized.
31
+ * 3. React / RSC runtime packages and framework host packages are
32
+ * externalized so the runtime supplies a single shared instance.
33
+ * 4. Other bare npm packages are bundled so the Docker runtime does not
34
+ * need to install every transitive page dependency separately.
35
+ */
36
+ export interface ExternalizeFrameworkOptions {
37
+ app: App;
38
+ /**
39
+ * Prefixes that should be BUNDLED (kept internal). Defaults to
40
+ * `@apps/` and `@libs/` which are the workspace aliases used by the
41
+ * framework's app template.
42
+ */
43
+ include?: string[];
44
+ /**
45
+ * Extra bare specifiers to force-externalize beyond the allowlist
46
+ * defaults. Not commonly needed since the default rule already
47
+ * externalizes every non-workspace specifier.
48
+ */
49
+ extra?: string[];
50
+ }
51
+
52
+ // Workspace package prefixes that must be BUNDLED so their sources go
53
+ // through the barrel-imports + `"use client"` transforms. Without this
54
+ // the RSC worker would resolve them at runtime — skipping the
55
+ // transforms entirely, which re-introduces the react-dom/client
56
+ // side-effects we wanted to strip from the server graph.
57
+ const DEFAULT_INCLUDE = ["akanjs/", "@apps/", "@libs/"];
58
+
59
+ // Packages that must stay external even when a broad allowlist would
60
+ // otherwise bundle them. These are runtime hosts or shared singleton
61
+ // framework packages; ordinary npm dependencies intentionally fall through
62
+ // to Bun's resolver and get bundled.
63
+ const DEFAULT_EXCLUDE_EXACT = new Set<string>(["akanjs/webkit", "akanjs/server", "@akanjs/cli", "@akanjs/devkit"]);
64
+ const DEFAULT_EXCLUDE_PREFIX = ["akanjs/server/", "@akanjs/cli/", "@akanjs/devkit/"];
65
+ const RUNTIME_EXTERNAL_EXACT = new Set<string>([
66
+ "react",
67
+ "react-dom",
68
+ "react/jsx-runtime",
69
+ "react/jsx-dev-runtime",
70
+ "react-server-dom-webpack",
71
+ "react-server-dom-webpack/server.node",
72
+ "react-server-dom-webpack/client.node",
73
+ "react-server-dom-webpack/client.browser",
74
+ ]);
75
+ const RUNTIME_EXTERNAL_PREFIX = ["react-dom/", "react-server-dom-webpack/"];
76
+
77
+ // File extensions Bun can load when a bare subpath points at an
78
+ // extensionless path. Ordered to prefer TS over JS, since workspace
79
+ // sources are authored in TypeScript.
80
+ const CANDIDATE_EXTS = [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs"];
81
+
82
+ export async function createExternalizeFrameworkPlugin(options: ExternalizeFrameworkOptions): Promise<BunPlugin> {
83
+ const tsconfig = await options.app.getTsConfig();
84
+ const includePrefixes = options.include ?? DEFAULT_INCLUDE;
85
+ const extraExact = new Set(options.extra ?? []);
86
+ const workspaceRoot = options.app.workspace.workspaceRoot;
87
+ const tsconfigPaths = tsconfig.compilerOptions.paths ?? {};
88
+ // Pre-compute the tsconfig root-only entries (`akanjs/client` → …)
89
+ // so we can resolve their subpaths by concatenating the remainder.
90
+ const rootEntries = Object.entries(tsconfigPaths)
91
+ .filter(([k]) => !k.endsWith("/*"))
92
+ .map(([k, v]) => ({ pkg: k, entryFile: v[0] ?? null }))
93
+ .filter((e): e is { pkg: string; entryFile: string } => e.entryFile !== null);
94
+ const wildcardEntries = Object.entries(tsconfigPaths)
95
+ .filter(([k]) => k.endsWith("/*"))
96
+ .map(([k, v]) => ({ prefix: k.slice(0, -1), replacements: v }))
97
+ .sort((a, b) => b.prefix.length - a.prefix.length);
98
+
99
+ async function resolveWorkspaceSubpath(spec: string): Promise<string | null> {
100
+ if (!workspaceRoot) return null;
101
+ // Wildcard `@apps/*` / `@libs/*` → direct filesystem path.
102
+ for (const { prefix, replacements } of wildcardEntries) {
103
+ if (!spec.startsWith(prefix)) continue;
104
+ const suffix = spec.slice(prefix.length);
105
+ for (const repl of replacements) {
106
+ const replPath = repl?.endsWith("/*") ? repl.slice(0, -1) : (repl ?? "");
107
+ if (!replPath) continue;
108
+ const candidate = path.resolve(workspaceRoot, replPath + suffix);
109
+ const hit = await firstExisting(candidate);
110
+ if (hit) return hit;
111
+ }
112
+ }
113
+ // Root-only tsconfig entries — resolve the subpath against the
114
+ // package directory. E.g. `akanjs/client` → `pkgs/akanjs/client/index.ts`
115
+ // so `akanjs/client/cookie` → `pkgs/akanjs/client/cookie`.
116
+ for (const { pkg, entryFile } of rootEntries) {
117
+ if (spec !== pkg && !spec.startsWith(`${pkg}/`)) continue;
118
+ if (spec === pkg) continue; // the package root itself — let Bun's tsconfig resolver handle it
119
+ const suffix = spec.slice(pkg.length + 1);
120
+ const pkgDir = path.dirname(path.resolve(workspaceRoot, entryFile));
121
+ const candidate = path.join(pkgDir, suffix);
122
+ const hit = await firstExisting(candidate);
123
+ if (hit) return hit;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ return {
129
+ name: "akan-externalize-framework",
130
+ setup(build) {
131
+ build.onResolve({ filter: /.*/ }, async (args) => {
132
+ const spec = args.path;
133
+ // Relative imports always stay inlined — they anchor to the
134
+ // current package's source tree and cannot leak cross-package.
135
+ // `.` and `..` (without trailing slash) are valid relative
136
+ // specifiers too (equivalent to `./index` / `../index`).
137
+ if (spec === "." || spec === ".." || spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/"))
138
+ return undefined;
139
+ // Force-external first (overrides the include allowlist).
140
+ if (extraExact.has(spec) || DEFAULT_EXCLUDE_EXACT.has(spec)) return { path: spec, external: true };
141
+ for (const prefix of DEFAULT_EXCLUDE_PREFIX) {
142
+ if (spec.startsWith(prefix)) return { path: spec, external: true };
143
+ }
144
+ if (RUNTIME_EXTERNAL_EXACT.has(spec)) return { path: spec, external: true };
145
+ for (const prefix of RUNTIME_EXTERNAL_PREFIX) {
146
+ if (spec.startsWith(prefix)) return { path: spec, external: true };
147
+ }
148
+ // Workspace allowlist — resolve the subpath to an absolute
149
+ // on-disk file so Bun.build can read it. Without this, bare
150
+ // specifiers like `akanjs/client/cookie` (produced by the
151
+ // barrel rewriter) fail Bun's default resolution (only root
152
+ // tsconfig-paths entry exists for `akanjs/client`) and get
153
+ // silently left as externals.
154
+ for (const prefix of includePrefixes) {
155
+ if (!spec.startsWith(prefix)) continue;
156
+ const resolved = await resolveWorkspaceSubpath(spec);
157
+ if (resolved) return { path: resolved };
158
+ // Fall through to let Bun attempt its own resolution for the
159
+ // package root (`akanjs/client`) — tsconfig `paths` handles
160
+ // that case directly.
161
+ return undefined;
162
+ }
163
+ // Everything else (ordinary npm dependencies like dayjs / clsx /
164
+ // immer) is bundled into the pages artifact.
165
+ return undefined;
166
+ });
167
+ },
168
+ };
169
+ }
170
+
171
+ async function firstExisting(basePath: string): Promise<string | null> {
172
+ // Direct file with its own extension.
173
+ if (await Bun.file(basePath).exists()) return basePath;
174
+ // Try each candidate extension.
175
+ for (const ext of CANDIDATE_EXTS) {
176
+ const candidate = `${basePath}${ext}`;
177
+ if (await Bun.file(candidate).exists()) return candidate;
178
+ }
179
+ // Directory with `index.<ext>` inside.
180
+ for (const ext of CANDIDATE_EXTS) {
181
+ const candidate = path.join(basePath, `index${ext}`);
182
+ if (await Bun.file(candidate).exists()) return candidate;
183
+ }
184
+ return null;
185
+ }