@arcote.tech/arc-cli 0.7.25 → 0.7.26

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/dist/index.js CHANGED
@@ -34512,6 +34512,7 @@ import {
34512
34512
  writeFileSync as writeFileSync6
34513
34513
  } from "fs";
34514
34514
  import { basename as basename2, dirname as dirname6, join as join8, relative as relative3 } from "path";
34515
+ import { builtinModules } from "module";
34515
34516
  init_i18n();
34516
34517
  init_compile();
34517
34518
 
@@ -34712,14 +34713,54 @@ function singleReactPlugin(rootDir) {
34712
34713
  }
34713
34714
  };
34714
34715
  }
34715
- function serverExternalsPlugin() {
34716
+ var BUILTIN_SET = new Set(builtinModules);
34717
+ function pkgNameOf(spec) {
34718
+ if (spec.startsWith("@"))
34719
+ return spec.split("/").slice(0, 2).join("/");
34720
+ return spec.split("/")[0];
34721
+ }
34722
+ function isBuiltinSpec(spec) {
34723
+ if (spec.startsWith("node:") || spec.startsWith("bun:") || spec === "bun")
34724
+ return true;
34725
+ return BUILTIN_SET.has(spec) || BUILTIN_SET.has(pkgNameOf(spec));
34726
+ }
34727
+ function versionFromImporter(importer, name) {
34728
+ let dir = importer ? dirname6(importer) : "";
34729
+ while (dir && dir !== dirname6(dir)) {
34730
+ const pj = join8(dir, "package.json");
34731
+ if (existsSync7(pj)) {
34732
+ try {
34733
+ const json = JSON.parse(readFileSync7(pj, "utf-8"));
34734
+ const spec = json.dependencies?.[name] ?? json.peerDependencies?.[name] ?? json.devDependencies?.[name];
34735
+ if (typeof spec === "string")
34736
+ return spec;
34737
+ } catch {}
34738
+ }
34739
+ dir = dirname6(dir);
34740
+ }
34741
+ return;
34742
+ }
34743
+ function npmExternalsPlugin(workspaceNames, recorded) {
34716
34744
  return {
34717
- name: "server-externals",
34745
+ name: "npm-externals",
34718
34746
  setup(build2) {
34719
- build2.onResolve({ filter: /^(@aws-sdk|@smithy)\// }, (args) => ({
34720
- path: args.path,
34721
- external: true
34722
- }));
34747
+ build2.onResolve({ filter: /^[^./]/ }, (args) => {
34748
+ if (isBuiltinSpec(args.path)) {
34749
+ return { path: args.path, external: true };
34750
+ }
34751
+ const name = pkgNameOf(args.path);
34752
+ if (workspaceNames.has(name))
34753
+ return null;
34754
+ if (name.startsWith("@arcote.tech/") && !FRAMEWORK_PEER_SET.has(name)) {
34755
+ return null;
34756
+ }
34757
+ const version = versionFromImporter(args.importer, name);
34758
+ if (version?.startsWith("workspace:"))
34759
+ return null;
34760
+ if (!recorded.has(name))
34761
+ recorded.set(name, version);
34762
+ return { path: args.path, external: true };
34763
+ });
34723
34764
  }
34724
34765
  };
34725
34766
  }
@@ -34758,6 +34799,7 @@ var CONTEXT_CLIENTS = [
34758
34799
  ];
34759
34800
  var SERVER_DEFINES = { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" };
34760
34801
  var SERVER_ENTRY_FILE = "_server.js";
34802
+ var SERVER_EXTERNALS_FILE = "_externals.json";
34761
34803
  function discoverPackages(rootDir) {
34762
34804
  const rootPkg = JSON.parse(readFileSync7(join8(rootDir, "package.json"), "utf-8"));
34763
34805
  const workspaceGlobs = rootPkg.workspaces ?? [];
@@ -34944,9 +34986,14 @@ async function buildServerApp(rootDir, serverDir, packages, cache, noCache) {
34944
34986
  defines: SERVER_DEFINES
34945
34987
  });
34946
34988
  const entryFileAbs = join8(serverDir, SERVER_ENTRY_FILE);
34989
+ const externalsFileAbs = join8(serverDir, SERVER_EXTERNALS_FILE);
34947
34990
  if (!noCache && isCacheHit(cache, unitId, inputHash, [entryFileAbs])) {
34948
34991
  console.log(` \u2713 cached: ${unitId}`);
34949
- return { entryFile: SERVER_ENTRY_FILE, cached: true };
34992
+ return {
34993
+ entryFile: SERVER_ENTRY_FILE,
34994
+ cached: true,
34995
+ externals: readServerExternals(externalsFileAbs)
34996
+ };
34950
34997
  }
34951
34998
  console.log(` building: ${unitId} (${contexts.length} server modules)`);
34952
34999
  for (const f of readdirSync4(serverDir)) {
@@ -34959,6 +35006,7 @@ async function buildServerApp(rootDir, serverDir, packages, cache, noCache) {
34959
35006
  writeFileSync6(entrySrc, contexts.map((p) => `import "${p.name}";`).join(`
34960
35007
  `) + `
34961
35008
  `);
35009
+ const recorded = new Map;
34962
35010
  let result;
34963
35011
  try {
34964
35012
  result = await Bun.build({
@@ -34971,8 +35019,8 @@ async function buildServerApp(rootDir, serverDir, packages, cache, noCache) {
34971
35019
  external,
34972
35020
  plugins: [
34973
35021
  jsxDevShimPlugin(),
34974
- serverExternalsPlugin(),
34975
- workspaceSourcePlugin(srcByName)
35022
+ workspaceSourcePlugin(srcByName),
35023
+ npmExternalsPlugin(new Set(srcByName.keys()), recorded)
34976
35024
  ],
34977
35025
  define: SERVER_DEFINES
34978
35026
  });
@@ -34991,9 +35039,22 @@ async function buildServerApp(rootDir, serverDir, packages, cache, noCache) {
34991
35039
  if (basename2(entryOut.path) !== SERVER_ENTRY_FILE) {
34992
35040
  throw new Error(`Server app build: unexpected entry name ${basename2(entryOut.path)} (wanted ${SERVER_ENTRY_FILE})`);
34993
35041
  }
35042
+ const externals = [...recorded].filter(([name]) => !FRAMEWORK_PEER_SET.has(name)).map(([name, version]) => ({ name, version })).sort((a, b) => a.name.localeCompare(b.name));
35043
+ writeFileSync6(externalsFileAbs, JSON.stringify(externals, null, 2) + `
35044
+ `);
34994
35045
  const outputHash = sha256OfDir(serverDir);
34995
35046
  updateCache(cache, unitId, inputHash, { outputHash });
34996
- return { entryFile: SERVER_ENTRY_FILE, cached: false };
35047
+ return { entryFile: SERVER_ENTRY_FILE, cached: false, externals };
35048
+ }
35049
+ function readServerExternals(path4) {
35050
+ if (!existsSync7(path4))
35051
+ return [];
35052
+ try {
35053
+ const parsed = JSON.parse(readFileSync7(path4, "utf-8"));
35054
+ return Array.isArray(parsed) ? parsed : [];
35055
+ } catch {
35056
+ return [];
35057
+ }
34997
35058
  }
34998
35059
  async function buildBrowserApp(rootDir, outDir, plan, cache, noCache, i18nCollector) {
34999
35060
  mkdirSync6(outDir, { recursive: true });
@@ -35476,9 +35537,10 @@ import { createHash } from "crypto";
35476
35537
  import { existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
35477
35538
  import { basename as basename4, dirname as dirname7, join as join11 } from "path";
35478
35539
  import { fileURLToPath as fileURLToPath6 } from "url";
35479
- function collectFrameworkDeps(arcDir, rootDir, packages, sharedDeps = []) {
35540
+ function collectFrameworkDeps(arcDir, rootDir, packages, sharedDeps = [], serverExternals = []) {
35480
35541
  mkdirSync8(arcDir, { recursive: true });
35481
35542
  const versions = resolveFrameworkVersions(rootDir, packages);
35543
+ mergeServerExternals(versions, rootDir, packages, serverExternals);
35482
35544
  for (const { name, version } of sharedDeps) {
35483
35545
  versions[name] = version;
35484
35546
  }
@@ -35529,18 +35591,10 @@ function resolveFrameworkVersions(rootDir, packages) {
35529
35591
  const required = new Set(FRAMEWORK_PEERS);
35530
35592
  for (const pkg of packages) {
35531
35593
  const peers = pkg.packageJson.peerDependencies ?? {};
35532
- const deps = pkg.packageJson.dependencies ?? {};
35533
35594
  for (const name of Object.keys(peers)) {
35534
35595
  if (name.startsWith("@arcote.tech/"))
35535
35596
  required.add(name);
35536
35597
  }
35537
- for (const [name, spec] of Object.entries(deps)) {
35538
- if (spec.startsWith("workspace:"))
35539
- continue;
35540
- if (name.startsWith("@arcote.tech/") || !isFrameworkExternal(name))
35541
- continue;
35542
- required.add(name);
35543
- }
35544
35598
  }
35545
35599
  const out = {};
35546
35600
  for (const name of required) {
@@ -35549,20 +35603,28 @@ function resolveFrameworkVersions(rootDir, packages) {
35549
35603
  out[name] = rootSpec;
35550
35604
  continue;
35551
35605
  }
35552
- let found;
35553
- for (const pkg of packages) {
35554
- const spec = (pkg.packageJson.dependencies ?? {})[name] ?? (pkg.packageJson.peerDependencies ?? {})[name];
35555
- if (spec) {
35556
- found = spec;
35557
- break;
35558
- }
35559
- }
35560
- out[name] = found ?? "*";
35606
+ out[name] = findWorkspaceSpec(packages, name) ?? "*";
35561
35607
  }
35562
35608
  return out;
35563
35609
  }
35564
- function isFrameworkExternal(_name) {
35565
- return true;
35610
+ function findWorkspaceSpec(packages, name) {
35611
+ for (const pkg of packages) {
35612
+ const spec = (pkg.packageJson.dependencies ?? {})[name] ?? (pkg.packageJson.peerDependencies ?? {})[name];
35613
+ if (spec)
35614
+ return spec;
35615
+ }
35616
+ return;
35617
+ }
35618
+ function mergeServerExternals(versions, rootDir, packages, externals) {
35619
+ const rootPkg = JSON.parse(readFileSync10(join11(rootDir, "package.json"), "utf-8"));
35620
+ const rootDeps = rootPkg.dependencies ?? {};
35621
+ const rootDevDeps = rootPkg.devDependencies ?? {};
35622
+ for (const { name, version } of externals) {
35623
+ if (versions[name])
35624
+ continue;
35625
+ const installed = readInstalledVersion(rootDir, name);
35626
+ versions[name] = rootDeps[name] ?? rootDevDeps[name] ?? version ?? findWorkspaceSpec(packages, name) ?? (installed ? `^${installed}` : "*");
35627
+ }
35566
35628
  }
35567
35629
 
35568
35630
  // src/platform/shared.ts
@@ -35631,7 +35693,7 @@ async function buildAll(ws, opts = {}) {
35631
35693
  assertOneModulePerPackage(ws.packages);
35632
35694
  await buildContextPackages(ws.rootDir, ws.packages, cache, noCache);
35633
35695
  const serverDir = join12(ws.arcDir, "server");
35634
- const { entryFile: serverEntry } = await buildServerApp(ws.rootDir, serverDir, ws.packages, cache, noCache);
35696
+ const { entryFile: serverEntry, externals: serverExternals } = await buildServerApp(ws.rootDir, serverDir, ws.packages, cache, noCache);
35635
35697
  const accessMap = await extractAccessMap(ws.rootDir, join12(serverDir, serverEntry));
35636
35698
  mkdirSync9(ws.arcDir, { recursive: true });
35637
35699
  writeFileSync9(join12(ws.arcDir, "access.json"), JSON.stringify(accessMap, null, 2) + `
@@ -35647,7 +35709,7 @@ async function buildAll(ws, opts = {}) {
35647
35709
  ]);
35648
35710
  const { finalizeTranslations: finalizeTranslations3 } = await Promise.resolve().then(() => (init_i18n(), exports_i18n));
35649
35711
  await finalizeTranslations3(ws.rootDir, ws.arcDir, i18nCollector);
35650
- collectFrameworkDeps(ws.arcDir, ws.rootDir, ws.packages);
35712
+ collectFrameworkDeps(ws.arcDir, ws.rootDir, ws.packages, [], serverExternals);
35651
35713
  saveBuildCache(ws.arcDir, cache);
35652
35714
  const finalManifest = assembleManifest(ws, browserResult, cache);
35653
35715
  writeFileSync9(join12(ws.arcDir, "manifest.json"), JSON.stringify(finalManifest, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.25",
3
+ "version": "0.7.26",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,13 +12,13 @@
12
12
  "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform --external '@opentelemetry/*' && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.25",
16
- "@arcote.tech/arc-ds": "^0.7.25",
17
- "@arcote.tech/arc-react": "^0.7.25",
18
- "@arcote.tech/arc-host": "^0.7.25",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.25",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.25",
21
- "@arcote.tech/arc-otel": "^0.7.25",
15
+ "@arcote.tech/arc": "^0.7.26",
16
+ "@arcote.tech/arc-ds": "^0.7.26",
17
+ "@arcote.tech/arc-react": "^0.7.26",
18
+ "@arcote.tech/arc-host": "^0.7.26",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.26",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.26",
21
+ "@arcote.tech/arc-otel": "^0.7.26",
22
22
  "@opentelemetry/api": "^1.9.0",
23
23
  "@opentelemetry/api-logs": "^0.57.0",
24
24
  "@opentelemetry/core": "^1.30.0",
@@ -31,7 +31,7 @@
31
31
  "@opentelemetry/sdk-trace-base": "^1.30.0",
32
32
  "@opentelemetry/sdk-trace-node": "^1.30.0",
33
33
  "@opentelemetry/semantic-conventions": "^1.27.0",
34
- "@arcote.tech/platform": "^0.7.25",
34
+ "@arcote.tech/platform": "^0.7.26",
35
35
  "@clack/prompts": "^0.9.0",
36
36
  "commander": "^11.1.0",
37
37
  "chokidar": "^3.5.3",
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { basename, dirname, join } from "path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { FRAMEWORK_PEERS, FRAMEWORK_PEER_SET } from "./framework-peers";
6
- import type { WorkspacePackage } from "./module-builder";
6
+ import { readInstalledVersion } from "./hash";
7
+ import type { ServerExternal, WorkspacePackage } from "./module-builder";
7
8
 
8
9
  export { FRAMEWORK_PEERS };
9
10
 
@@ -37,10 +38,17 @@ export function collectFrameworkDeps(
37
38
  rootDir: string,
38
39
  packages: WorkspacePackage[],
39
40
  sharedDeps: ReadonlyArray<{ name: string; version: string }> = [],
41
+ serverExternals: ReadonlyArray<ServerExternal> = [],
40
42
  ): CollectedDeps {
41
43
  mkdirSync(arcDir, { recursive: true });
42
44
 
43
45
  const versions = resolveFrameworkVersions(rootDir, packages);
46
+ // npm packages the server bundle left external — the runtime image must
47
+ // install them into /app/node_modules. This is the ONLY source for deps
48
+ // reached through a fragment the consumer never declares (e.g.
49
+ // `@arcote.tech/arc-files` → `@aws-sdk/client-s3`): the bundle records what
50
+ // it actually externalized, so the manifest tracks real usage, no whitelist.
51
+ mergeServerExternals(versions, rootDir, packages, serverExternals);
44
52
  // Shared deps (discovered across ≥ 2 workspace packages) join the framework
45
53
  // in the global manifest — installed once into <arcDir>/node_modules/ and
46
54
  // served via the shell so each module's browser bundle leaves them external.
@@ -178,12 +186,13 @@ function resolveFrameworkVersions(
178
186
  packages: WorkspacePackage[],
179
187
  ): Record<string, string> {
180
188
  // The framework manifest installs into /app/node_modules in the deploy
181
- // image. It needs to cover:
189
+ // image. resolveFrameworkVersions covers the singleton @arcote.tech/* set:
182
190
  // 1. Static framework peers (FRAMEWORK_PEERS) — always present
183
191
  // 2. User-extension @arcote.tech/* peers (e.g. arc-ai-openai) declared
184
192
  // as peerDeps on user context packages
185
- // 3. npm runtime deps that server bundles leave external (non-workspace
186
- // deps not yet bundled inline)
193
+ // Plain npm runtime deps the server bundle leaves external are added
194
+ // separately by mergeServerExternals (driven by what the bundle actually
195
+ // externalized, not by what workspace packages happen to declare).
187
196
  // Resolution order for each: root package.json wins, then any workspace
188
197
  // package's deps/peerDeps, then "*" as last resort.
189
198
  const rootPkg = JSON.parse(
@@ -196,18 +205,10 @@ function resolveFrameworkVersions(
196
205
  const required = new Set<string>(FRAMEWORK_PEERS);
197
206
  for (const pkg of packages) {
198
207
  const peers = pkg.packageJson.peerDependencies ?? {};
199
- const deps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
200
208
  // Any @arcote.tech/* peer beyond the static list (e.g. arc-ai adapters).
201
209
  for (const name of Object.keys(peers)) {
202
210
  if (name.startsWith("@arcote.tech/")) required.add(name);
203
211
  }
204
- // npm deps (non-workspace) — server bundles leave them external, so the
205
- // image needs them in /app/node_modules.
206
- for (const [name, spec] of Object.entries(deps)) {
207
- if (spec.startsWith("workspace:")) continue;
208
- if (name.startsWith("@arcote.tech/") || !isFrameworkExternal(name)) continue;
209
- required.add(name);
210
- }
211
212
  }
212
213
 
213
214
  const out: Record<string, string> = {};
@@ -217,32 +218,58 @@ function resolveFrameworkVersions(
217
218
  out[name] = rootSpec;
218
219
  continue;
219
220
  }
220
- let found: string | undefined;
221
- for (const pkg of packages) {
222
- const spec =
223
- (pkg.packageJson.dependencies ?? {})[name] ??
224
- (pkg.packageJson.peerDependencies ?? {})[name];
225
- if (spec) {
226
- found = spec;
227
- break;
228
- }
229
- }
230
- out[name] = found ?? "*";
221
+ out[name] = findWorkspaceSpec(packages, name) ?? "*";
231
222
  }
232
223
  return out;
233
224
  }
234
225
 
226
+ /** Version spec for `name` declared by any workspace package, if any. */
227
+ function findWorkspaceSpec(
228
+ packages: WorkspacePackage[],
229
+ name: string,
230
+ ): string | undefined {
231
+ for (const pkg of packages) {
232
+ const spec =
233
+ (pkg.packageJson.dependencies ?? {})[name] ??
234
+ (pkg.packageJson.peerDependencies ?? {})[name];
235
+ if (spec) return spec;
236
+ }
237
+ return undefined;
238
+ }
239
+
235
240
  /**
236
- * True if a non-arc npm dep is one the image must install globally for
237
- * runtime resolution. Today we include everything that user context packages
238
- * declare as deps but isn't `workspace:` — server bundles leave them external
239
- * so they MUST be present in /app/node_modules.
240
- *
241
- * Future optimization: bundle these inline into the server bundle to avoid
242
- * the install layer entirely. Left as a follow-up.
241
+ * Add server-bundle externals to the install manifest. For each, resolve a
242
+ * version spec by cascade so the image installs something compatible even when
243
+ * the consumer never declared the dep:
244
+ * 1. consumer root deps/devDeps (their explicit pin wins)
245
+ * 2. spec recorded from the importing package (e.g. arc-files peerDeps)
246
+ * 3. any workspace package's declaration
247
+ * 4. the version actually installed in node_modules (caret-ranged)
248
+ * 5. "*" as last resort
249
+ * Already-resolved names (framework peers) are left untouched.
243
250
  */
244
- function isFrameworkExternal(_name: string): boolean {
245
- return true;
251
+ function mergeServerExternals(
252
+ versions: Record<string, string>,
253
+ rootDir: string,
254
+ packages: WorkspacePackage[],
255
+ externals: ReadonlyArray<ServerExternal>,
256
+ ): void {
257
+ const rootPkg = JSON.parse(
258
+ readFileSync(join(rootDir, "package.json"), "utf-8"),
259
+ );
260
+ const rootDeps = (rootPkg.dependencies ?? {}) as Record<string, string>;
261
+ const rootDevDeps = (rootPkg.devDependencies ?? {}) as Record<string, string>;
262
+
263
+ for (const { name, version } of externals) {
264
+ if (versions[name]) continue;
265
+ const installed = readInstalledVersion(rootDir, name);
266
+ versions[name] =
267
+ rootDeps[name] ??
268
+ rootDevDeps[name] ??
269
+ version ??
270
+ findWorkspaceSpec(packages, name) ??
271
+ (installed ? `^${installed}` : "*");
272
+ }
246
273
  }
247
274
 
248
275
  function sha256OfFiles(paths: string[]): string {
@@ -8,6 +8,7 @@ import {
8
8
  writeFileSync,
9
9
  } from "fs";
10
10
  import { basename, dirname, join, relative } from "path";
11
+ import { builtinModules } from "node:module";
11
12
  import type { BuildManifest, ModuleDescriptor } from "@arcote.tech/platform";
12
13
  import { buildTypeDeclarations } from "../utils/build";
13
14
  import { i18nExtractPlugin, finalizeTranslations } from "../i18n";
@@ -18,7 +19,11 @@ import {
18
19
  type BuildCache,
19
20
  } from "./build-cache";
20
21
  import type { ChunkPlan } from "./chunk-planner";
21
- import { SHELL_EXTERNALS, FRAMEWORK_PEERS } from "./framework-peers";
22
+ import {
23
+ SHELL_EXTERNALS,
24
+ FRAMEWORK_PEERS,
25
+ FRAMEWORK_PEER_SET,
26
+ } from "./framework-peers";
22
27
  import {
23
28
  readInstalledVersion,
24
29
  sha256Hex,
@@ -97,30 +102,101 @@ function singleReactPlugin(rootDir: string): import("bun").BunPlugin {
97
102
  };
98
103
  }
99
104
 
105
+ /** One external the server bundle leaves for the deploy image to install. */
106
+ export interface ServerExternal {
107
+ /** Package name (subpath stripped): `@aws-sdk/client-s3`, `react`, … */
108
+ readonly name: string;
109
+ /** Version spec read from the importing package's package.json, if found. */
110
+ readonly version?: string;
111
+ }
112
+
113
+ const BUILTIN_SET = new Set<string>(builtinModules);
114
+
115
+ /** Package name from a bare specifier, dropping any subpath. */
116
+ function pkgNameOf(spec: string): string {
117
+ if (spec.startsWith("@")) return spec.split("/").slice(0, 2).join("/");
118
+ return spec.split("/")[0];
119
+ }
120
+
121
+ /** node:/bun: builtins — external by `target: "bun"`, never installed. */
122
+ function isBuiltinSpec(spec: string): boolean {
123
+ if (spec.startsWith("node:") || spec.startsWith("bun:") || spec === "bun")
124
+ return true;
125
+ return BUILTIN_SET.has(spec) || BUILTIN_SET.has(pkgNameOf(spec));
126
+ }
127
+
100
128
  /**
101
- * Marks heavy server-side packages as external. Bun.build's `external: string[]`
102
- * accepts only literal package names wildcard like `@aws-sdk/*` is treated
103
- * verbatim and doesn't match anything. This plugin uses `onResolve` regex to
104
- * catch any subpackage matching the prefix and marks it external.
105
- *
106
- * Removes ~10-15 MB of AWS SDK v3 from every server bundle that transitively
107
- * pulls `@arcote.tech/arc-files` (e.g. `@ndt/content` was 340 MB before).
129
+ * Best-effort version spec for `name` as declared by the package that imports
130
+ * it. Walks up from the importer to the nearest package.json that lists `name`
131
+ * in deps/peerDeps/devDeps e.g. `@aws-sdk/client-s3` resolves to the
132
+ * `^3.x` spec in `@arcote.tech/arc-files`'s peerDependencies. The collector
133
+ * falls back to root deps / installed version when this returns undefined.
134
+ */
135
+ function versionFromImporter(importer: string, name: string): string | undefined {
136
+ let dir = importer ? dirname(importer) : "";
137
+ while (dir && dir !== dirname(dir)) {
138
+ const pj = join(dir, "package.json");
139
+ if (existsSync(pj)) {
140
+ try {
141
+ const json = JSON.parse(readFileSync(pj, "utf-8"));
142
+ const spec =
143
+ json.dependencies?.[name] ??
144
+ json.peerDependencies?.[name] ??
145
+ json.devDependencies?.[name];
146
+ if (typeof spec === "string") return spec;
147
+ } catch {
148
+ // unreadable package.json — keep walking
149
+ }
150
+ }
151
+ dir = dirname(dir);
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ /**
157
+ * Structural externalization for the combined server bundle: inline only
158
+ * first-party code (workspace `@ndt/*` source — handled earlier by
159
+ * `workspaceSourcePlugin` — plus non-peer `@arcote.tech/*` fragments like
160
+ * `arc-files`), and leave EVERY third-party npm package external. The deploy
161
+ * image installs externals into `/app/node_modules` anyway, so inlining them
162
+ * only bloats the bundle. Replaces the old hardcoded `@aws-sdk|@smithy` regex
163
+ * (a band-aid from the 1.1 GB nested-dist era, since fixed by the single
164
+ * source-based build) — no package-name whitelist to keep in sync.
108
165
  *
109
- * Runtime requirement: each external package must be available in
110
- * `node_modules/` at server startup (Bun resolves bare specifiers). Consumer
111
- * apps declare them in root `package.json` dependencies.
166
+ * Records each externalized package + the version spec of the package that
167
+ * imports it, so `collectFrameworkDeps` can emit them into the install
168
+ * manifest. This is what closes the gap for deps reached through a fragment
169
+ * the consumer never declares (arc-files → aws-sdk).
112
170
  */
113
- function serverExternalsPlugin(): import("bun").BunPlugin {
171
+ function npmExternalsPlugin(
172
+ workspaceNames: ReadonlySet<string>,
173
+ recorded: Map<string, string | undefined>,
174
+ ): import("bun").BunPlugin {
114
175
  return {
115
- name: "server-externals",
176
+ name: "npm-externals",
116
177
  setup(build) {
117
- // @aws-sdk/* and @smithy/* full AWS SDK v3 + low-level signing/HTTP
118
- // primitives it depends on. Add more prefixes here if other heavy
119
- // server-only libs appear (e.g. `@google-cloud/*`).
120
- build.onResolve({ filter: /^(@aws-sdk|@smithy)\// }, (args) => ({
121
- path: args.path,
122
- external: true,
123
- }));
178
+ // Bare specifiers only. An exact workspace name (`@ndt/foo`) is resolved
179
+ // to source earlier by workspaceSourcePlugin and never reaches here; a
180
+ // workspace SUBPATH (`@ndt/foo/bar`) does, and must NOT be externalized.
181
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
182
+ if (isBuiltinSpec(args.path)) {
183
+ return { path: args.path, external: true };
184
+ }
185
+ const name = pkgNameOf(args.path);
186
+ // First-party workspace package (subpath import) — let Bun resolve and
187
+ // inline it; `workspace:*` is not installable from npm.
188
+ if (workspaceNames.has(name)) return null;
189
+ // First-party non-peer fragments inline from source/dist so
190
+ // SERVER_DEFINES (ONLY_SERVER…) apply and import cycles stay safe.
191
+ if (name.startsWith("@arcote.tech/") && !FRAMEWORK_PEER_SET.has(name)) {
192
+ return null;
193
+ }
194
+ const version = versionFromImporter(args.importer, name);
195
+ // Defensive: any `workspace:` dep is first-party — never externalize it.
196
+ if (version?.startsWith("workspace:")) return null;
197
+ if (!recorded.has(name)) recorded.set(name, version);
198
+ return { path: args.path, external: true };
199
+ });
124
200
  },
125
201
  };
126
202
  }
@@ -201,6 +277,11 @@ const SERVER_DEFINES = { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT
201
277
  * auto-emitted `chunk-<hash>.js` siblings are pulled in transitively. */
202
278
  export const SERVER_ENTRY_FILE = "_server.js";
203
279
 
280
+ /** Sidecar listing third-party packages the server bundle left external.
281
+ * Written next to the bundle so a cached rebuild can still feed the install
282
+ * manifest (collectFrameworkDeps) without re-running Bun.build. */
283
+ export const SERVER_EXTERNALS_FILE = "_externals.json";
284
+
204
285
  export interface WorkspacePackage {
205
286
  name: string;
206
287
  path: string;
@@ -502,6 +583,12 @@ export async function buildContextPackages(
502
583
  export interface ServerAppResult {
503
584
  readonly entryFile: string;
504
585
  readonly cached: boolean;
586
+ /**
587
+ * Third-party npm packages the bundle left external — the deploy image must
588
+ * install exactly these. Fed to `collectFrameworkDeps`. Empty on a cache hit
589
+ * (the manifest from the cached build is already on disk).
590
+ */
591
+ readonly externals: ReadonlyArray<ServerExternal>;
505
592
  }
506
593
 
507
594
  export async function buildServerApp(
@@ -548,9 +635,17 @@ export async function buildServerApp(
548
635
  });
549
636
 
550
637
  const entryFileAbs = join(serverDir, SERVER_ENTRY_FILE);
638
+ const externalsFileAbs = join(serverDir, SERVER_EXTERNALS_FILE);
551
639
  if (!noCache && isCacheHit(cache, unitId, inputHash, [entryFileAbs])) {
552
640
  console.log(` ✓ cached: ${unitId}`);
553
- return { entryFile: SERVER_ENTRY_FILE, cached: true };
641
+ // collectFrameworkDeps runs every build (uncached) and needs the external
642
+ // set to build the install manifest — reload it from the sidecar written
643
+ // by the last real build so a cache hit doesn't drop deps from the manifest.
644
+ return {
645
+ entryFile: SERVER_ENTRY_FILE,
646
+ cached: true,
647
+ externals: readServerExternals(externalsFileAbs),
648
+ };
554
649
  }
555
650
 
556
651
  console.log(` building: ${unitId} (${contexts.length} server modules)`);
@@ -571,6 +666,10 @@ export async function buildServerApp(
571
666
  contexts.map((p) => `import "${p.name}";`).join("\n") + "\n",
572
667
  );
573
668
 
669
+ // npmExternalsPlugin records every third-party package it leaves external,
670
+ // plus the version spec of whatever imports it (best-effort).
671
+ const recorded = new Map<string, string | undefined>();
672
+
574
673
  let result;
575
674
  try {
576
675
  result = await Bun.build({
@@ -586,8 +685,11 @@ export async function buildServerApp(
586
685
  external,
587
686
  plugins: [
588
687
  jsxDevShimPlugin(),
589
- serverExternalsPlugin(),
688
+ // workspaceSourcePlugin MUST precede npmExternalsPlugin: the first
689
+ // onResolve to return a result wins, so `@ndt/*` source is pinned
690
+ // before the catch-all externalizer can mark it external.
590
691
  workspaceSourcePlugin(srcByName),
692
+ npmExternalsPlugin(new Set(srcByName.keys()), recorded),
591
693
  ],
592
694
  define: SERVER_DEFINES,
593
695
  });
@@ -610,9 +712,28 @@ export async function buildServerApp(
610
712
  );
611
713
  }
612
714
 
715
+ // Third-party externals only — framework peers carry their own resolution in
716
+ // collectFrameworkDeps, so drop them here to avoid double-sourcing versions.
717
+ const externals: ServerExternal[] = [...recorded]
718
+ .filter(([name]) => !FRAMEWORK_PEER_SET.has(name))
719
+ .map(([name, version]) => ({ name, version }))
720
+ .sort((a, b) => a.name.localeCompare(b.name));
721
+ writeFileSync(externalsFileAbs, JSON.stringify(externals, null, 2) + "\n");
722
+
613
723
  const outputHash = sha256OfDir(serverDir);
614
724
  updateCache(cache, unitId, inputHash, { outputHash });
615
- return { entryFile: SERVER_ENTRY_FILE, cached: false };
725
+ return { entryFile: SERVER_ENTRY_FILE, cached: false, externals };
726
+ }
727
+
728
+ /** Read the externals sidecar written by the last real server build. */
729
+ function readServerExternals(path: string): ServerExternal[] {
730
+ if (!existsSync(path)) return [];
731
+ try {
732
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
733
+ return Array.isArray(parsed) ? (parsed as ServerExternal[]) : [];
734
+ } catch {
735
+ return [];
736
+ }
616
737
  }
617
738
 
618
739
 
@@ -172,13 +172,8 @@ export async function buildAll(
172
172
  // into multi-hundred-MB files. The deploy image COPYs this dir wholesale;
173
173
  // loadServerContext + the access extractor import the entry, chunks ride along.
174
174
  const serverDir = join(ws.arcDir, "server");
175
- const { entryFile: serverEntry } = await buildServerApp(
176
- ws.rootDir,
177
- serverDir,
178
- ws.packages,
179
- cache,
180
- noCache,
181
- );
175
+ const { entryFile: serverEntry, externals: serverExternals } =
176
+ await buildServerApp(ws.rootDir, serverDir, ws.packages, cache, noCache);
182
177
 
183
178
  // Phase 2 — extract access metadata (token name + hasCheck per module) in
184
179
  // an isolated subprocess that imports the combined server bundle. MUST run
@@ -223,7 +218,7 @@ export async function buildAll(
223
218
  // Phase 5 — framework peer manifest at `<arcDir>/package.json`. Used by
224
219
  // the deploy image build (single `bun install` for all peers, one copy
225
220
  // shared across module bundles).
226
- collectFrameworkDeps(ws.arcDir, ws.rootDir, ws.packages);
221
+ collectFrameworkDeps(ws.arcDir, ws.rootDir, ws.packages, [], serverExternals);
227
222
 
228
223
  saveBuildCache(ws.arcDir, cache);
229
224