@atolljs/cli 0.1.8 → 0.1.9

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/cli.js CHANGED
@@ -209,6 +209,9 @@ var NPMRC = `# Supply-chain hardening \u2014 read by every npm command run in th
209
209
  # lockfile (requires npm >= 11.10 \u2014 shipped with Node 26; older npm
210
210
  # silently ignores this key).
211
211
  min-release-age=7
212
+ # First-party packages must install even inside the age window (npm 11.10+;
213
+ # older npm silently ignores the key).
214
+ min-release-age-exclude[]=@atolljs/*
212
215
  # Install lifecycle scripts are the dominant malware vector \u2014 none run
213
216
  # during install/ci. If a native dep ever legitimately needs one:
214
217
  # npm rebuild <pkg> --ignore-scripts=false
@@ -222,7 +225,7 @@ dist/
222
225
  `;
223
226
  function memoryContract(name) {
224
227
  const c = camel(name);
225
- return `import { defineSharedMemory, field, mz } from '@atolljs/core';
228
+ return `import { defineSharedMemory, field, reef } from '@atolljs/core';
226
229
 
227
230
  /**
228
231
  * ${c} shared memory \u2014 the single source of truth for state both threads
@@ -238,10 +241,10 @@ export const ${c}Memory = defineSharedMemory({
238
241
  // Structured snapshots \u2014 one inline record, rewritten wholesale.
239
242
  state: {
240
243
  summary: field.object({
241
- schema: mz.object({
242
- total: mz.f64(),
243
- errors: mz.f64(),
244
- updatedAt: mz.u32(),
244
+ schema: reef.object({
245
+ total: reef.f64(),
246
+ errors: reef.f64(),
247
+ updatedAt: reef.u32(),
245
248
  }),
246
249
  }),
247
250
  },
@@ -1789,8 +1792,10 @@ ${serviceImport}${memImport}
1789
1792
  imports: [
1790
1793
  AtollModule.registerPool({
1791
1794
  name: '${name}',
1792
- // webpack emits the worker entry as its own chunk \u2014 keep the URL inline
1793
- worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
1795
+ // the emitted worker bundle \u2014 keep the URL inline. Under a Vite SSR
1796
+ // build declare ${name}.worker.ts as a build input; on webpack point
1797
+ // this at the './${name}.worker.ts' source instead.
1798
+ worker: () => new Worker(new URL('./${name}.worker.js', import.meta.url)),
1794
1799
  ${memLine} poolSize: 'auto',
1795
1800
  }),
1796
1801
  ],
@@ -1881,7 +1886,7 @@ function nestjsHousedFiles(name, dir) {
1881
1886
  // runs in. Message-only by default; to share another pool's buffer, wrap
1882
1887
  // the factory:
1883
1888
  // worker: withSharedBuffer(
1884
- // () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
1889
+ // () => new Worker(new URL('./${name}.worker.js', import.meta.url)),
1885
1890
  // () => getAtollPool('<other-pool>')?.sharedBuffer,
1886
1891
  // )
1887
1892
  import { Module } from '@nestjs/common';
@@ -1892,7 +1897,10 @@ import { AtollModule } from '@atolljs/nestjs';
1892
1897
  imports: [
1893
1898
  AtollModule.registerPool({
1894
1899
  name: '${name}',
1895
- worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
1900
+ // the emitted worker bundle \u2014 keep the URL inline. Under a Vite SSR
1901
+ // build declare ${name}.worker.ts as a build input; on webpack point
1902
+ // this at the './${name}.worker.ts' source instead.
1903
+ worker: () => new Worker(new URL('./${name}.worker.js', import.meta.url)),
1896
1904
  poolSize: 2,
1897
1905
  }),
1898
1906
  ],
@@ -2245,6 +2253,63 @@ initDevtools({
2245
2253
  initDevtools({ session: { name: '${name}' } });
2246
2254
  `;
2247
2255
  }
2256
+ function nestjsViteConfig() {
2257
+ return `import { readdirSync } from 'node:fs';
2258
+ import { basename } from 'node:path';
2259
+ import { defineConfig } from 'vite';
2260
+
2261
+ // Every *.worker.ts under src/ is its own entry \u2014 pool configs reference the
2262
+ // emitted './x.worker.js' via new URL(..., import.meta.url). Keys are
2263
+ // basenames, so output is flat and the literal resolves from dist/main.js.
2264
+ const workerInputs = Object.fromEntries(
2265
+ readdirSync('src', { recursive: true, withFileTypes: true })
2266
+ .filter((e) => e.isFile() && e.name.endsWith('.worker.ts'))
2267
+ .map((e) => {
2268
+ const dir = (e.parentPath ?? e.path).replaceAll('\\\\', '/');
2269
+ return [basename(e.name, '.ts'), \`\${dir}/\${e.name}\`];
2270
+ }),
2271
+ );
2272
+
2273
+ export default defineConfig({
2274
+ build: {
2275
+ // Node SSR build: node builtins + node_modules stay external.
2276
+ ssr: true,
2277
+ outDir: 'dist',
2278
+ rollupOptions: {
2279
+ input: { main: 'src/main.ts', ...workerInputs },
2280
+ // Flat layout: a './x.worker.js' literal resolves to dist/<name>.worker.js
2281
+ // whether it lands in main.js or a shared chunk (the default assets/
2282
+ // subdir would put chunks one level too deep).
2283
+ output: { entryFileNames: '[name].js', chunkFileNames: '[name]-[hash].js' },
2284
+ },
2285
+ },
2286
+ });
2287
+ `;
2288
+ }
2289
+ function devServerScript() {
2290
+ return `// Dev loop: vite's Rollup watcher rebuilds main + worker bundles on change
2291
+ // while \`node --watch\` restarts the app whenever dist/ updates \u2014 a worker
2292
+ // reload is a respawn anyway. Programmatic API: no npx/.cmd spawn (Node
2293
+ // 20.12+ blocks .cmd spawn without shell:true on Windows \u2192 EINVAL).
2294
+ import { spawn } from 'node:child_process';
2295
+ import { build } from 'vite';
2296
+
2297
+ await build(); // initial build \u2014 throws and exits on failure
2298
+ await build({ build: { watch: {} } }); // keeps the process alive
2299
+
2300
+ const child = spawn(process.execPath, ['--watch', 'dist/main.js'], {
2301
+ stdio: 'inherit',
2302
+ });
2303
+
2304
+ const shutdown = () => {
2305
+ child.kill();
2306
+ process.exit(0);
2307
+ };
2308
+ process.on('SIGINT', shutdown);
2309
+ process.on('SIGTERM', shutdown);
2310
+ process.on('exit', () => child.kill());
2311
+ `;
2312
+ }
2248
2313
 
2249
2314
  // src/commands.ts
2250
2315
  var NODE_HOSTS = ["node", "nestjs"];
@@ -2272,7 +2337,14 @@ async function offerInstall(ctx, project, pkgs, installAll = false) {
2272
2337
  cwd: project.root ?? ctx.cwd,
2273
2338
  stdio: "inherit"
2274
2339
  });
2275
- if (r.status !== 0) throw new Error(`install failed \u2014 run \`${cmd}\` manually`);
2340
+ if (r.status !== 0) {
2341
+ if (project.framework === "nestjs") {
2342
+ ctx.io.warn(
2343
+ "if npm reported ERESOLVE: the installed @nestjs/* major may be outside the range @atolljs/nestjs supports (@nestjs/common and @nestjs/core 11.x or 12.x). Align all @nestjs/* packages to a supported major, or rerun the install with --legacy-peer-deps."
2344
+ );
2345
+ }
2346
+ throw new Error(`install failed \u2014 run \`${cmd}\` manually`);
2347
+ }
2276
2348
  }
2277
2349
  async function runInit(ctx) {
2278
2350
  const project = detectProject(ctx.cwd);
@@ -2289,36 +2361,54 @@ async function runInit(ctx) {
2289
2361
  await writeTree(ctx.io, ctx.cwd, [{ path: rel(join3(project.root, ".npmrc")), content: NPMRC }], {
2290
2362
  force: hasFlag(ctx.args, "force")
2291
2363
  });
2292
- await offerInstall(ctx, project, ATOLL_DEPS[project.framework]);
2293
2364
  const atollDir = join3(project.srcDir, "atoll");
2294
2365
  const host = hostOf(project.framework);
2295
2366
  const name = flag(ctx.args, "name") ?? "app";
2296
2367
  const files = [
2297
- { path: `${rel(atollDir)}/${name}.memory.ts`, content: memoryContract(name) },
2298
- {
2299
- path: `${rel(atollDir)}/${name}.worker.ts`,
2300
- content: workerEntry(name, {
2301
- host,
2302
- memoryImport: `./${name}.memory`,
2303
- memoryName: `${camel(name)}Memory`,
2304
- demoMemoryWrite: true
2305
- })
2306
- },
2307
- {
2308
- path: `${rel(atollDir)}/${name}.ts`,
2309
- content: workerClientFile(name, {
2310
- host,
2311
- memoryImport: `./${name}.memory`,
2312
- memoryName: `${camel(name)}Memory`,
2313
- // src/atoll/x.ts → <root>/dist/x.worker.js
2314
- distUrl: `${relative(atollDir, join3(project.root, "dist")).replaceAll("\\", "/")}/${name}.worker.js`
2315
- })
2316
- }
2368
+ { path: `${rel(atollDir)}/${name}.memory.ts`, content: memoryContract(name) }
2317
2369
  ];
2370
+ if (project.framework === "nestjs") {
2371
+ files.push(
2372
+ ...nestjsServiceFiles(name, rel(atollDir), {
2373
+ import_: `./${name}.memory`,
2374
+ export_: `${camel(name)}Memory`
2375
+ })
2376
+ );
2377
+ } else {
2378
+ files.push(
2379
+ {
2380
+ path: `${rel(atollDir)}/${name}.worker.ts`,
2381
+ content: workerEntry(name, {
2382
+ host,
2383
+ memoryImport: `./${name}.memory`,
2384
+ memoryName: `${camel(name)}Memory`,
2385
+ demoMemoryWrite: true
2386
+ })
2387
+ },
2388
+ {
2389
+ path: `${rel(atollDir)}/${name}.ts`,
2390
+ content: workerClientFile(name, {
2391
+ host,
2392
+ memoryImport: `./${name}.memory`,
2393
+ memoryName: `${camel(name)}Memory`,
2394
+ // src/atoll/x.ts → <root>/dist/x.worker.js
2395
+ distUrl: `${relative(atollDir, join3(project.root, "dist")).replaceAll("\\", "/")}/${name}.worker.js`
2396
+ })
2397
+ }
2398
+ );
2399
+ }
2318
2400
  await writeTree(ctx.io, ctx.cwd, files, { force: hasFlag(ctx.args, "force") });
2401
+ await offerInstall(ctx, project, ATOLL_DEPS[project.framework]);
2319
2402
  ctx.io.print("");
2320
2403
  ctx.io.print(ctx.io.fmt.strong("next steps:"));
2321
- if (host === "node") {
2404
+ if (project.framework === "nestjs") {
2405
+ ctx.io.print(
2406
+ ` import ${ctx.io.fmt.accent(`${pascal(name)}Module`)} into AppModule, then inject ${ctx.io.fmt.accent(`${pascal(name)}Service`)} \u2014 ${ctx.io.fmt.accent(`service.echo('hi')`)} runs in a worker`
2407
+ );
2408
+ ctx.io.print(
2409
+ ` worker entries emit as ${ctx.io.fmt.accent(`${name}.worker.js`)} \u2014 declare them as build inputs (see docs/frameworks/nestjs.md)`
2410
+ );
2411
+ } else if (host === "node") {
2322
2412
  ctx.io.print(
2323
2413
  ` bundle the worker entry (see the comment in ${name}.ts), then call ${ctx.io.fmt.accent(`${camel(name)}.echo('hi')`)}`
2324
2414
  );
@@ -2332,6 +2422,115 @@ async function runInit(ctx) {
2332
2422
  );
2333
2423
  return 0;
2334
2424
  }
2425
+ function rewriteWorkerUrls(srcDir) {
2426
+ const urlRe = /(new URL\(\s*['"`][^'"`]*?\.worker)\.ts(['"`])/g;
2427
+ const changed = [];
2428
+ const walk = (dir) => {
2429
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
2430
+ const p = join3(dir, e.name);
2431
+ if (e.isDirectory()) walk(p);
2432
+ else if (e.name.endsWith(".ts")) {
2433
+ const src = readFileSync2(p, "utf8");
2434
+ const next = src.replace(urlRe, "$1.js$2");
2435
+ if (next !== src) {
2436
+ writeFileSync2(p, next);
2437
+ changed.push(p);
2438
+ }
2439
+ }
2440
+ }
2441
+ };
2442
+ walk(srcDir);
2443
+ return changed;
2444
+ }
2445
+ async function runConvert(ctx) {
2446
+ const target = ctx.args._[1];
2447
+ if (target !== "vite") {
2448
+ ctx.io.error(
2449
+ `unknown convert target ${ctx.io.fmt.accent(target ?? "(missing)")} \u2014 expected 'vite'`
2450
+ );
2451
+ return 1;
2452
+ }
2453
+ const project = detectProject(ctx.cwd);
2454
+ if (!project.root || !project.pkg) {
2455
+ ctx.io.error("no package.json found \u2014 cd into the project to convert");
2456
+ return 1;
2457
+ }
2458
+ if (project.framework !== "nestjs") {
2459
+ ctx.io.error(
2460
+ `convert vite supports nestjs projects \u2014 detected "${project.framework}"`
2461
+ );
2462
+ return 1;
2463
+ }
2464
+ const root = project.root;
2465
+ const rel = (abs) => relative(ctx.cwd, abs).replaceAll("\\", "/");
2466
+ const force = hasFlag(ctx.args, "force");
2467
+ const srcDir = project.srcDir;
2468
+ await writeTree(
2469
+ ctx.io,
2470
+ ctx.cwd,
2471
+ [
2472
+ { path: rel(join3(root, "vite.config.ts")), content: nestjsViteConfig() },
2473
+ { path: rel(join3(root, "dev.mjs")), content: devServerScript() }
2474
+ ],
2475
+ { force }
2476
+ );
2477
+ const rewritten = rewriteWorkerUrls(srcDir);
2478
+ for (const p of rewritten) ctx.io.print(` rewrote worker URL \u2014 ${rel(p)}`);
2479
+ const pkgPath = join3(root, "package.json");
2480
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
2481
+ pkg.type = "module";
2482
+ pkg.scripts = {
2483
+ ...pkg.scripts,
2484
+ dev: "node dev.mjs",
2485
+ build: "tsc --noEmit && vite build",
2486
+ start: "node dist/main.js"
2487
+ };
2488
+ pkg.devDependencies = { ...pkg.devDependencies, vite: pkg.devDependencies?.vite ?? "^8.3.1" };
2489
+ writeFileSync2(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
2490
+ ctx.io.print(` package.json \u2014 type:module, dev/build/start scripts, vite devDep`);
2491
+ const tsconfigPath = join3(root, "tsconfig.json");
2492
+ try {
2493
+ const raw = readFileSync2(tsconfigPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
2494
+ const tsconfig = JSON.parse(raw);
2495
+ tsconfig.compilerOptions ??= {};
2496
+ Object.assign(tsconfig.compilerOptions, {
2497
+ module: "ESNext",
2498
+ moduleResolution: "bundler",
2499
+ noEmit: true
2500
+ });
2501
+ tsconfig.include = [.../* @__PURE__ */ new Set([...tsconfig.include ?? ["src"], "vite.config.ts"])];
2502
+ writeFileSync2(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
2503
+ ctx.io.print(` tsconfig.json \u2014 module ESNext, moduleResolution bundler, noEmit`);
2504
+ } catch {
2505
+ ctx.io.warn(
2506
+ "could not patch tsconfig.json \u2014 set module: ESNext, moduleResolution: bundler, noEmit: true manually"
2507
+ );
2508
+ }
2509
+ const mainTs = join3(srcDir, "main.ts");
2510
+ if (existsSync3(mainTs)) {
2511
+ const src = readFileSync2(mainTs, "utf8");
2512
+ if (!src.includes("reflect-metadata")) {
2513
+ writeFileSync2(mainTs, `import 'reflect-metadata';
2514
+ ${src}`);
2515
+ ctx.io.print(` main.ts \u2014 prepended import 'reflect-metadata'`);
2516
+ }
2517
+ }
2518
+ for (const leftover of ["nest-cli.json", "webpack.config.js", "webpack.config.cjs"]) {
2519
+ if (existsSync3(join3(root, leftover))) {
2520
+ ctx.io.print(ctx.io.fmt.dim(` ${leftover} is no longer used \u2014 safe to delete`));
2521
+ }
2522
+ }
2523
+ await offerInstall(ctx, project, ["vite"], true);
2524
+ ctx.io.print("");
2525
+ ctx.io.print(ctx.io.fmt.strong("converted to a Vite SSR build:"));
2526
+ ctx.io.print(
2527
+ ` ${ctx.io.fmt.accent("npm run dev")} \u2014 vite build --watch + node --watch dist/main.js`
2528
+ );
2529
+ ctx.io.print(
2530
+ ` ${ctx.io.fmt.accent("npm run build")} \u2192 dist/ \xB7 ${ctx.io.fmt.accent("npm start")} \u2014 node dist/main.js`
2531
+ );
2532
+ return 0;
2533
+ }
2335
2534
  var ADD_KINDS = [
2336
2535
  "worker",
2337
2536
  "memory",
@@ -2883,6 +3082,11 @@ var HELP = (fmt) => `${fmt.strong("atoll")} \u2014 bootstrap multithreading ${fm
2883
3082
  Frameworks: react vue solid svelte angular nestjs nextjs node
2884
3083
  [--dir <path>] [--memory <name>|--no-memory] [--variant <v>] [--force]
2885
3084
 
3085
+ ${fmt.accent("atoll convert vite")} [--force]
3086
+ Convert a NestJS project from the webpack build to a Vite SSR build \u2014
3087
+ vite.config.ts + dev.mjs, worker URLs repointed at emitted bundles,
3088
+ package.json/tsconfig patched.
3089
+
2886
3090
  ${fmt.accent("atoll doctor")} [--fix]
2887
3091
  Check the setup \u2014 deps, lockfile, .npmrc policy, bundler-detectable
2888
3092
  worker entries, COOP/COEP headers. --fix writes a hardened .npmrc.
@@ -2910,6 +3114,8 @@ async function runCli(argv, io, cwd) {
2910
3114
  return await runInit(ctx);
2911
3115
  case "add":
2912
3116
  return await runAdd(ctx);
3117
+ case "convert":
3118
+ return await runConvert(ctx);
2913
3119
  case "doctor":
2914
3120
  return runDoctor(ctx);
2915
3121
  case "devtools":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "atoll — scaffold worker pools, shared-memory contracts, and framework islands (experimental)",
5
5
  "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { consoleIo, type Fmt, type Io } from './io.ts';
14
14
  import { hasFlag, parseArgs } from './parse.ts';
15
- import { runAdd, runCreate, runDevtools, runDoctor, runInit, type Ctx } from './commands.ts';
15
+ import { runAdd, runConvert, runCreate, runDevtools, runDoctor, runInit, type Ctx } from './commands.ts';
16
16
 
17
17
  const HELP = (fmt: Fmt) => `${fmt.strong('atoll')} — bootstrap multithreading ${fmt.dim('(experimental)')}
18
18
 
@@ -44,6 +44,11 @@ const HELP = (fmt: Fmt) => `${fmt.strong('atoll')} — bootstrap multithreading
44
44
  Frameworks: react vue solid svelte angular nestjs nextjs node
45
45
  [--dir <path>] [--memory <name>|--no-memory] [--variant <v>] [--force]
46
46
 
47
+ ${fmt.accent('atoll convert vite')} [--force]
48
+ Convert a NestJS project from the webpack build to a Vite SSR build —
49
+ vite.config.ts + dev.mjs, worker URLs repointed at emitted bundles,
50
+ package.json/tsconfig patched.
51
+
47
52
  ${fmt.accent('atoll doctor')} [--fix]
48
53
  Check the setup — deps, lockfile, .npmrc policy, bundler-detectable
49
54
  worker entries, COOP/COEP headers. --fix writes a hardened .npmrc.
@@ -77,6 +82,8 @@ export async function runCli(argv: readonly string[], io: Io, cwd: string): Prom
77
82
  return await runInit(ctx);
78
83
  case 'add':
79
84
  return await runAdd(ctx);
85
+ case 'convert':
86
+ return await runConvert(ctx);
80
87
  case 'doctor':
81
88
  return runDoctor(ctx);
82
89
  case 'devtools':
package/src/commands.ts CHANGED
@@ -61,7 +61,14 @@ async function offerInstall(
61
61
  cwd: project.root ?? ctx.cwd,
62
62
  stdio: 'inherit',
63
63
  });
64
- if (r.status !== 0) throw new Error(`install failed — run \`${cmd}\` manually`);
64
+ if (r.status !== 0) {
65
+ if (project.framework === 'nestjs') {
66
+ ctx.io.warn(
67
+ 'if npm reported ERESOLVE: the installed @nestjs/* major may be outside the range @atolljs/nestjs supports (@nestjs/common and @nestjs/core 11.x or 12.x). Align all @nestjs/* packages to a supported major, or rerun the install with --legacy-peer-deps.',
68
+ );
69
+ }
70
+ throw new Error(`install failed — run \`${cmd}\` manually`);
71
+ }
65
72
  }
66
73
 
67
74
  /* ── atoll init ─────────────────────────────────────────────────────────── */
@@ -84,39 +91,60 @@ export async function runInit(ctx: Ctx): Promise<number> {
84
91
  force: hasFlag(ctx.args, 'force'),
85
92
  });
86
93
 
87
- await offerInstall(ctx, project, ATOLL_DEPS[project.framework]);
88
-
89
- // A working spine: contract + worker + client, already wired together.
94
+ // A working spine — written BEFORE the install so a failed install still
95
+ // leaves a complete, retryable scaffold. NestJS gets the DI-native spine
96
+ // (service facade + registerPool module + worker entry); other hosts get
97
+ // the contract + worker + client trio.
90
98
  const atollDir = join(project.srcDir, 'atoll');
91
99
  const host = hostOf(project.framework);
92
100
  const name = flag(ctx.args, 'name') ?? 'app';
93
101
  const files: OutFile[] = [
94
102
  { path: `${rel(atollDir)}/${name}.memory.ts`, content: T.memoryContract(name) },
95
- {
96
- path: `${rel(atollDir)}/${name}.worker.ts`,
97
- content: T.workerEntry(name, {
98
- host,
99
- memoryImport: `./${name}.memory`,
100
- memoryName: `${camel(name)}Memory`,
101
- demoMemoryWrite: true,
102
- }),
103
- },
104
- {
105
- path: `${rel(atollDir)}/${name}.ts`,
106
- content: T.workerClientFile(name, {
107
- host,
108
- memoryImport: `./${name}.memory`,
109
- memoryName: `${camel(name)}Memory`,
110
- // src/atoll/x.ts → <root>/dist/x.worker.js
111
- distUrl: `${relative(atollDir, join(project.root, 'dist')).replaceAll('\\', '/')}/${name}.worker.js`,
112
- }),
113
- },
114
103
  ];
104
+ if (project.framework === 'nestjs') {
105
+ files.push(
106
+ ...T.nestjsServiceFiles(name, rel(atollDir), {
107
+ import_: `./${name}.memory`,
108
+ export_: `${camel(name)}Memory`,
109
+ }),
110
+ );
111
+ } else {
112
+ files.push(
113
+ {
114
+ path: `${rel(atollDir)}/${name}.worker.ts`,
115
+ content: T.workerEntry(name, {
116
+ host,
117
+ memoryImport: `./${name}.memory`,
118
+ memoryName: `${camel(name)}Memory`,
119
+ demoMemoryWrite: true,
120
+ }),
121
+ },
122
+ {
123
+ path: `${rel(atollDir)}/${name}.ts`,
124
+ content: T.workerClientFile(name, {
125
+ host,
126
+ memoryImport: `./${name}.memory`,
127
+ memoryName: `${camel(name)}Memory`,
128
+ // src/atoll/x.ts → <root>/dist/x.worker.js
129
+ distUrl: `${relative(atollDir, join(project.root, 'dist')).replaceAll('\\', '/')}/${name}.worker.js`,
130
+ }),
131
+ },
132
+ );
133
+ }
115
134
  await writeTree(ctx.io, ctx.cwd, files, { force: hasFlag(ctx.args, 'force') });
116
135
 
136
+ await offerInstall(ctx, project, ATOLL_DEPS[project.framework]);
137
+
117
138
  ctx.io.print('');
118
139
  ctx.io.print(ctx.io.fmt.strong('next steps:'));
119
- if (host === 'node') {
140
+ if (project.framework === 'nestjs') {
141
+ ctx.io.print(
142
+ ` import ${ctx.io.fmt.accent(`${pascal(name)}Module`)} into AppModule, then inject ${ctx.io.fmt.accent(`${pascal(name)}Service`)} — ${ctx.io.fmt.accent(`service.echo('hi')`)} runs in a worker`,
143
+ );
144
+ ctx.io.print(
145
+ ` worker entries emit as ${ctx.io.fmt.accent(`${name}.worker.js`)} — declare them as build inputs (see docs/frameworks/nestjs.md)`,
146
+ );
147
+ } else if (host === 'node') {
120
148
  ctx.io.print(
121
149
  ` bundle the worker entry (see the comment in ${name}.ts), then call ${ctx.io.fmt.accent(`${camel(name)}.echo('hi')`)}`,
122
150
  );
@@ -131,6 +159,139 @@ export async function runInit(ctx: Ctx): Promise<number> {
131
159
  return 0;
132
160
  }
133
161
 
162
+ /* ── atoll convert vite ─────────────────────────────────────────────────── */
163
+
164
+ /** Repoint `new URL('./x.worker.ts', import.meta.url)` literals at the emitted
165
+ * bundles — Vite SSR build takes workers as explicit inputs, so pool
166
+ * factories reference `./x.worker.js`. Returns the files it changed. */
167
+ function rewriteWorkerUrls(srcDir: string): string[] {
168
+ const urlRe = /(new URL\(\s*['"`][^'"`]*?\.worker)\.ts(['"`])/g;
169
+ const changed: string[] = [];
170
+ const walk = (dir: string) => {
171
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
172
+ const p = join(dir, e.name);
173
+ if (e.isDirectory()) walk(p);
174
+ else if (e.name.endsWith('.ts')) {
175
+ const src = readFileSync(p, 'utf8');
176
+ const next = src.replace(urlRe, '$1.js$2');
177
+ if (next !== src) {
178
+ writeFileSync(p, next);
179
+ changed.push(p);
180
+ }
181
+ }
182
+ }
183
+ };
184
+ walk(srcDir);
185
+ return changed;
186
+ }
187
+
188
+ export async function runConvert(ctx: Ctx): Promise<number> {
189
+ const target = ctx.args._[1];
190
+ if (target !== 'vite') {
191
+ ctx.io.error(
192
+ `unknown convert target ${ctx.io.fmt.accent(target ?? '(missing)')} — expected 'vite'`,
193
+ );
194
+ return 1;
195
+ }
196
+ const project = detectProject(ctx.cwd);
197
+ if (!project.root || !project.pkg) {
198
+ ctx.io.error('no package.json found — cd into the project to convert');
199
+ return 1;
200
+ }
201
+ if (project.framework !== 'nestjs') {
202
+ ctx.io.error(
203
+ `convert vite supports nestjs projects — detected "${project.framework}"`,
204
+ );
205
+ return 1;
206
+ }
207
+ const root = project.root;
208
+ const rel = (abs: string) => relative(ctx.cwd, abs).replaceAll('\\', '/');
209
+ const force = hasFlag(ctx.args, 'force');
210
+ const srcDir = project.srcDir;
211
+
212
+ // Build files: SSR multi-entry config + the watch dev loop.
213
+ await writeTree(
214
+ ctx.io,
215
+ ctx.cwd,
216
+ [
217
+ { path: rel(join(root, 'vite.config.ts')), content: T.nestjsViteConfig() },
218
+ { path: rel(join(root, 'dev.mjs')), content: T.devServerScript() },
219
+ ],
220
+ { force },
221
+ );
222
+
223
+ // Pool factories reference the emitted worker bundles now.
224
+ const rewritten = rewriteWorkerUrls(srcDir);
225
+ for (const p of rewritten) ctx.io.print(` rewrote worker URL — ${rel(p)}`);
226
+
227
+ // package.json: ESM, vite scripts, vite devDep.
228
+ const pkgPath = join(root, 'package.json');
229
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
230
+ pkg.type = 'module';
231
+ pkg.scripts = {
232
+ ...pkg.scripts,
233
+ dev: 'node dev.mjs',
234
+ build: 'tsc --noEmit && vite build',
235
+ start: 'node dist/main.js',
236
+ };
237
+ pkg.devDependencies = { ...pkg.devDependencies, vite: pkg.devDependencies?.vite ?? '^8.3.1' };
238
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
239
+ ctx.io.print(` package.json — type:module, dev/build/start scripts, vite devDep`);
240
+
241
+ // tsconfig: bundler resolution so extensionless imports keep typechecking;
242
+ // typecheck-only (noEmit) since vite owns emit.
243
+ const tsconfigPath = join(root, 'tsconfig.json');
244
+ try {
245
+ const raw = readFileSync(tsconfigPath, 'utf8')
246
+ .replace(/\/\*[\s\S]*?\*\//g, '')
247
+ .replace(/(^|\s)\/\/.*$/gm, '$1')
248
+ .replace(/,(\s*[}\]])/g, '$1');
249
+ const tsconfig = JSON.parse(raw);
250
+ tsconfig.compilerOptions ??= {};
251
+ Object.assign(tsconfig.compilerOptions, {
252
+ module: 'ESNext',
253
+ moduleResolution: 'bundler',
254
+ noEmit: true,
255
+ });
256
+ tsconfig.include = [...new Set([...(tsconfig.include ?? ['src']), 'vite.config.ts'])];
257
+ writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
258
+ ctx.io.print(` tsconfig.json — module ESNext, moduleResolution bundler, noEmit`);
259
+ } catch {
260
+ ctx.io.warn(
261
+ 'could not patch tsconfig.json — set module: ESNext, moduleResolution: bundler, noEmit: true manually',
262
+ );
263
+ }
264
+
265
+ // esbuild drops the metadata decorators need only when emitDecoratorMetadata
266
+ // carries injections — the shim is still required before any decorated class.
267
+ const mainTs = join(srcDir, 'main.ts');
268
+ if (existsSync(mainTs)) {
269
+ const src = readFileSync(mainTs, 'utf8');
270
+ if (!src.includes('reflect-metadata')) {
271
+ writeFileSync(mainTs, `import 'reflect-metadata';\n${src}`);
272
+ ctx.io.print(` main.ts — prepended import 'reflect-metadata'`);
273
+ }
274
+ }
275
+
276
+ for (const leftover of ['nest-cli.json', 'webpack.config.js', 'webpack.config.cjs']) {
277
+ if (existsSync(join(root, leftover))) {
278
+ ctx.io.print(ctx.io.fmt.dim(` ${leftover} is no longer used — safe to delete`));
279
+ }
280
+ }
281
+
282
+ await offerInstall(ctx, project, ['vite'], true);
283
+
284
+ ctx.io.print('');
285
+ ctx.io.print(ctx.io.fmt.strong('converted to a Vite SSR build:'));
286
+ ctx.io.print(
287
+ ` ${ctx.io.fmt.accent('npm run dev')} — vite build --watch + node --watch dist/main.js`,
288
+ );
289
+ ctx.io.print(
290
+ ` ${ctx.io.fmt.accent('npm run build')} → dist/ · ${ctx.io.fmt.accent('npm start')} — node dist/main.js`,
291
+ );
292
+ return 0;
293
+ }
294
+
134
295
  /* ── atoll add [framework] <kind> [variant] [name] ───────────────────────── */
135
296
 
136
297
  const ADD_KINDS = [
package/src/templates.ts CHANGED
@@ -9,6 +9,9 @@ export const NPMRC = `# Supply-chain hardening — read by every npm command run
9
9
  # lockfile (requires npm >= 11.10 — shipped with Node 26; older npm
10
10
  # silently ignores this key).
11
11
  min-release-age=7
12
+ # First-party packages must install even inside the age window (npm 11.10+;
13
+ # older npm silently ignores the key).
14
+ min-release-age-exclude[]=@atolljs/*
12
15
  # Install lifecycle scripts are the dominant malware vector — none run
13
16
  # during install/ci. If a native dep ever legitimately needs one:
14
17
  # npm rebuild <pkg> --ignore-scripts=false
@@ -26,7 +29,7 @@ dist/
26
29
 
27
30
  export function memoryContract(name: string): string {
28
31
  const c = camel(name);
29
- return `import { defineSharedMemory, field, mz } from '@atolljs/core';
32
+ return `import { defineSharedMemory, field, reef } from '@atolljs/core';
30
33
 
31
34
  /**
32
35
  * ${c} shared memory — the single source of truth for state both threads
@@ -42,10 +45,10 @@ export const ${c}Memory = defineSharedMemory({
42
45
  // Structured snapshots — one inline record, rewritten wholesale.
43
46
  state: {
44
47
  summary: field.object({
45
- schema: mz.object({
46
- total: mz.f64(),
47
- errors: mz.f64(),
48
- updatedAt: mz.u32(),
48
+ schema: reef.object({
49
+ total: reef.f64(),
50
+ errors: reef.f64(),
51
+ updatedAt: reef.u32(),
49
52
  }),
50
53
  }),
51
54
  },
@@ -1705,8 +1708,10 @@ ${serviceImport}${memImport}
1705
1708
  imports: [
1706
1709
  AtollModule.registerPool({
1707
1710
  name: '${name}',
1708
- // webpack emits the worker entry as its own chunk — keep the URL inline
1709
- worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
1711
+ // the emitted worker bundle — keep the URL inline. Under a Vite SSR
1712
+ // build declare ${name}.worker.ts as a build input; on webpack point
1713
+ // this at the './${name}.worker.ts' source instead.
1714
+ worker: () => new Worker(new URL('./${name}.worker.js', import.meta.url)),
1710
1715
  ${memLine} poolSize: 'auto',
1711
1716
  }),
1712
1717
  ],
@@ -1817,7 +1822,7 @@ export function nestjsHousedFiles(name: string, dir: string): OutFile[] {
1817
1822
  // runs in. Message-only by default; to share another pool's buffer, wrap
1818
1823
  // the factory:
1819
1824
  // worker: withSharedBuffer(
1820
- // () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
1825
+ // () => new Worker(new URL('./${name}.worker.js', import.meta.url)),
1821
1826
  // () => getAtollPool('<other-pool>')?.sharedBuffer,
1822
1827
  // )
1823
1828
  import { Module } from '@nestjs/common';
@@ -1828,7 +1833,10 @@ import { AtollModule } from '@atolljs/nestjs';
1828
1833
  imports: [
1829
1834
  AtollModule.registerPool({
1830
1835
  name: '${name}',
1831
- worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
1836
+ // the emitted worker bundle — keep the URL inline. Under a Vite SSR
1837
+ // build declare ${name}.worker.ts as a build input; on webpack point
1838
+ // this at the './${name}.worker.ts' source instead.
1839
+ worker: () => new Worker(new URL('./${name}.worker.js', import.meta.url)),
1832
1840
  poolSize: 2,
1833
1841
  }),
1834
1842
  ],
@@ -2226,3 +2234,77 @@ initDevtools({
2226
2234
  initDevtools({ session: { name: '${name}' } });
2227
2235
  `;
2228
2236
  }
2237
+
2238
+ /* ── `atoll convert vite` (nestjs → Vite SSR build) ─────────────────────── */
2239
+
2240
+ /**
2241
+ * Consumer-facing vite.config.ts for a converted NestJS project — no repo
2242
+ * aliases (published @atolljs/* packages resolve from node_modules). Every
2243
+ * src/**\/*.worker.ts is an explicit rollup input; output is flat so a
2244
+ * './x.worker.js' literal resolves from dist/main.js or a shared chunk.
2245
+ * Uses readdirSync recursive (Node 20.1+) rather than globSync (Node 22+).
2246
+ */
2247
+ export function nestjsViteConfig(): string {
2248
+ return `import { readdirSync } from 'node:fs';
2249
+ import { basename } from 'node:path';
2250
+ import { defineConfig } from 'vite';
2251
+
2252
+ // Every *.worker.ts under src/ is its own entry — pool configs reference the
2253
+ // emitted './x.worker.js' via new URL(..., import.meta.url). Keys are
2254
+ // basenames, so output is flat and the literal resolves from dist/main.js.
2255
+ const workerInputs = Object.fromEntries(
2256
+ readdirSync('src', { recursive: true, withFileTypes: true })
2257
+ .filter((e) => e.isFile() && e.name.endsWith('.worker.ts'))
2258
+ .map((e) => {
2259
+ const dir = (e.parentPath ?? e.path).replaceAll('\\\\', '/');
2260
+ return [basename(e.name, '.ts'), \`\${dir}/\${e.name}\`];
2261
+ }),
2262
+ );
2263
+
2264
+ export default defineConfig({
2265
+ build: {
2266
+ // Node SSR build: node builtins + node_modules stay external.
2267
+ ssr: true,
2268
+ outDir: 'dist',
2269
+ rollupOptions: {
2270
+ input: { main: 'src/main.ts', ...workerInputs },
2271
+ // Flat layout: a './x.worker.js' literal resolves to dist/<name>.worker.js
2272
+ // whether it lands in main.js or a shared chunk (the default assets/
2273
+ // subdir would put chunks one level too deep).
2274
+ output: { entryFileNames: '[name].js', chunkFileNames: '[name]-[hash].js' },
2275
+ },
2276
+ },
2277
+ });
2278
+ `;
2279
+ }
2280
+
2281
+ /**
2282
+ * `node --watch` + vite watcher dev loop for the converted project — a
2283
+ * worker reload is a respawn anyway, so watching dist/ is enough. Uses
2284
+ * vite's programmatic build API: no npx/.cmd spawn (Node 20.12+ blocks .cmd
2285
+ * spawn without shell:true on Windows → EINVAL).
2286
+ */
2287
+ export function devServerScript(): string {
2288
+ return `// Dev loop: vite's Rollup watcher rebuilds main + worker bundles on change
2289
+ // while \`node --watch\` restarts the app whenever dist/ updates — a worker
2290
+ // reload is a respawn anyway. Programmatic API: no npx/.cmd spawn (Node
2291
+ // 20.12+ blocks .cmd spawn without shell:true on Windows → EINVAL).
2292
+ import { spawn } from 'node:child_process';
2293
+ import { build } from 'vite';
2294
+
2295
+ await build(); // initial build — throws and exits on failure
2296
+ await build({ build: { watch: {} } }); // keeps the process alive
2297
+
2298
+ const child = spawn(process.execPath, ['--watch', 'dist/main.js'], {
2299
+ stdio: 'inherit',
2300
+ });
2301
+
2302
+ const shutdown = () => {
2303
+ child.kill();
2304
+ process.exit(0);
2305
+ };
2306
+ process.on('SIGINT', shutdown);
2307
+ process.on('SIGTERM', shutdown);
2308
+ process.on('exit', () => child.kill());
2309
+ `;
2310
+ }