@atolljs/cli 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +41 -2
- package/dist/cli.js +2261 -0
- package/package.json +34 -4
- package/src/cli.ts +97 -0
- package/src/commands.ts +626 -0
- package/src/files.ts +43 -0
- package/src/io.ts +127 -0
- package/src/parse.ts +53 -0
- package/src/project.ts +132 -0
- package/src/templates.ts +1632 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/io.ts
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { stdin, stdout, stderr } from "node:process";
|
|
6
|
+
import { styleText } from "node:util";
|
|
7
|
+
var PLAIN_FMT = { strong: (s) => s, dim: (s) => s, accent: (s) => s };
|
|
8
|
+
function consoleIo(interactive) {
|
|
9
|
+
const color = process.env.NO_COLOR === void 0 && (process.env.FORCE_COLOR !== void 0 || stdout.isTTY === true);
|
|
10
|
+
const style = (f) => (s) => color ? styleText(f, s) : s;
|
|
11
|
+
const fmt = color ? { strong: style("bold"), dim: style("dim"), accent: style("cyan") } : PLAIN_FMT;
|
|
12
|
+
const okMark = style("green")(" \u2713 ");
|
|
13
|
+
const warnMark = style("yellow")(" ! ");
|
|
14
|
+
const errorMark = style("red")(" \u2717 ");
|
|
15
|
+
const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
|
|
16
|
+
return {
|
|
17
|
+
interactive,
|
|
18
|
+
fmt,
|
|
19
|
+
print: (msg = "") => stdout.write(msg + "\n"),
|
|
20
|
+
ok: (msg) => stdout.write(`${okMark} ${msg}
|
|
21
|
+
`),
|
|
22
|
+
warn: (msg) => stdout.write(`${warnMark} ${msg}
|
|
23
|
+
`),
|
|
24
|
+
error: (msg) => stderr.write(`${errorMark} ${msg}
|
|
25
|
+
`),
|
|
26
|
+
async ask(question, fallback = "") {
|
|
27
|
+
if (!rl) return fallback;
|
|
28
|
+
const answer = (await rl.question(`${fmt.strong(question)} `)).trim();
|
|
29
|
+
return answer || fallback;
|
|
30
|
+
},
|
|
31
|
+
async confirm(question, def = false) {
|
|
32
|
+
if (!rl) return def;
|
|
33
|
+
const hint = fmt.dim(`[${def ? "Y/n" : "y/N"}]`);
|
|
34
|
+
const answer = (await rl.question(`${fmt.strong(question)} ${hint} `)).trim().toLowerCase();
|
|
35
|
+
return answer === "" ? def : answer === "y" || answer === "yes";
|
|
36
|
+
},
|
|
37
|
+
async select(question, choices, def) {
|
|
38
|
+
if (!rl) return def;
|
|
39
|
+
stdout.write(fmt.strong(question) + "\n");
|
|
40
|
+
choices.forEach(
|
|
41
|
+
(c, i) => stdout.write(
|
|
42
|
+
` ${fmt.accent(String(i + 1))}) ${c}${c === def ? fmt.dim(" (default)") : ""}
|
|
43
|
+
`
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
const answer = (await rl.question(`${fmt.dim(`choose [1-${choices.length}]:`)} `)).trim();
|
|
47
|
+
if (answer === "") return def;
|
|
48
|
+
const pick = choices[Number(answer) - 1] ?? choices.find((c) => c === answer);
|
|
49
|
+
return pick ?? def;
|
|
50
|
+
},
|
|
51
|
+
close: () => rl?.close()
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/parse.ts
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const _ = [];
|
|
58
|
+
const flags = {};
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const a = argv[i];
|
|
61
|
+
if (a === "--") {
|
|
62
|
+
_.push(...argv.slice(i + 1));
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (a.startsWith("--")) {
|
|
66
|
+
const eq = a.indexOf("=");
|
|
67
|
+
if (eq !== -1) {
|
|
68
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
69
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
70
|
+
flags[a.slice(2)] = argv[++i];
|
|
71
|
+
} else {
|
|
72
|
+
flags[a.slice(2)] = true;
|
|
73
|
+
}
|
|
74
|
+
} else if (a.startsWith("-") && a.length > 1) {
|
|
75
|
+
for (const ch of a.slice(1)) flags[ch] = true;
|
|
76
|
+
} else {
|
|
77
|
+
_.push(a);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { _, flags };
|
|
81
|
+
}
|
|
82
|
+
var flag = (args, name) => typeof args.flags[name] === "string" ? args.flags[name] : void 0;
|
|
83
|
+
var hasFlag = (args, ...names) => names.some((n) => args.flags[n] === true || typeof args.flags[n] === "string");
|
|
84
|
+
var camel = (name) => name.replace(/[-_](.)/g, (_m, c) => c.toUpperCase());
|
|
85
|
+
var pascal = (name) => {
|
|
86
|
+
const c = camel(name);
|
|
87
|
+
return c.charAt(0).toUpperCase() + c.slice(1);
|
|
88
|
+
};
|
|
89
|
+
var validName = (name) => /^[a-z][a-z0-9]*([-_][a-z0-9]+)*$/.test(name);
|
|
90
|
+
|
|
91
|
+
// src/commands.ts
|
|
92
|
+
import { spawnSync } from "node:child_process";
|
|
93
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
94
|
+
import { join as join3, relative } from "node:path";
|
|
95
|
+
|
|
96
|
+
// src/project.ts
|
|
97
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
98
|
+
import { dirname, join } from "node:path";
|
|
99
|
+
function findRoot(cwd) {
|
|
100
|
+
let dir = cwd;
|
|
101
|
+
for (; ; ) {
|
|
102
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
103
|
+
const parent = dirname(dir);
|
|
104
|
+
if (parent === dir) return null;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function readPkg(dir) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
var DEPS_OF = (pkg) => ({
|
|
116
|
+
...pkg?.dependencies ?? {},
|
|
117
|
+
...pkg?.devDependencies ?? {}
|
|
118
|
+
});
|
|
119
|
+
var DETECT = [
|
|
120
|
+
["nestjs", ["@nestjs/core", "@nestjs/common"]],
|
|
121
|
+
["nextjs", ["next"]],
|
|
122
|
+
["angular", ["@angular/core"]],
|
|
123
|
+
["svelte", ["svelte"]],
|
|
124
|
+
["solid", ["solid-js"]],
|
|
125
|
+
["vue", ["vue"]],
|
|
126
|
+
["react", ["react", "react-dom"]],
|
|
127
|
+
["node", ["@atolljs/node", "express", "fastify", "hono", "koa", "@nestjs/core"]]
|
|
128
|
+
];
|
|
129
|
+
function detectFramework(pkg) {
|
|
130
|
+
const deps = DEPS_OF(pkg);
|
|
131
|
+
for (const [fw, names] of DETECT) {
|
|
132
|
+
if (names.some((n) => deps[n] !== void 0)) return fw;
|
|
133
|
+
}
|
|
134
|
+
return "vanilla";
|
|
135
|
+
}
|
|
136
|
+
function detectPm(root) {
|
|
137
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
138
|
+
if (existsSync(join(root, "yarn.lock"))) return "yarn";
|
|
139
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun";
|
|
140
|
+
return "npm";
|
|
141
|
+
}
|
|
142
|
+
function detectProject(cwd) {
|
|
143
|
+
const root = findRoot(cwd);
|
|
144
|
+
if (!root) {
|
|
145
|
+
return { root: null, pkg: null, framework: "vanilla", pm: "npm", srcDir: cwd };
|
|
146
|
+
}
|
|
147
|
+
const pkg = readPkg(root);
|
|
148
|
+
return {
|
|
149
|
+
root,
|
|
150
|
+
pkg,
|
|
151
|
+
framework: detectFramework(pkg),
|
|
152
|
+
pm: detectPm(root),
|
|
153
|
+
srcDir: existsSync(join(root, "src")) ? join(root, "src") : root
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
var ATOLL_DEPS = {
|
|
157
|
+
react: ["@atolljs/core", "@atolljs/react", "@atolljs/react-island", "zod"],
|
|
158
|
+
vue: ["@atolljs/core", "@atolljs/vue", "@atolljs/vue-island", "zod"],
|
|
159
|
+
solid: ["@atolljs/core", "@atolljs/solidjs", "@atolljs/solid-island", "zod"],
|
|
160
|
+
svelte: ["@atolljs/core", "@atolljs/svelte", "@atolljs/svelte-island", "zod"],
|
|
161
|
+
angular: ["@atolljs/core", "@atolljs/angular", "@atolljs/angular-island", "zod"],
|
|
162
|
+
nextjs: ["@atolljs/core", "@atolljs/nextjs", "zod"],
|
|
163
|
+
nestjs: ["@atolljs/core", "@atolljs/nestjs", "@atolljs/node", "zod"],
|
|
164
|
+
node: ["@atolljs/core", "@atolljs/node", "zod"],
|
|
165
|
+
vanilla: ["@atolljs/core", "zod"]
|
|
166
|
+
};
|
|
167
|
+
var ISLAND_FRAMEWORKS = [
|
|
168
|
+
"react",
|
|
169
|
+
"vue",
|
|
170
|
+
"solid",
|
|
171
|
+
"svelte",
|
|
172
|
+
"angular"
|
|
173
|
+
];
|
|
174
|
+
var pmInstallCmd = (pm, pkgs) => `${pm} ${pm === "npm" ? "install" : "add"} ${pkgs.join(" ")}`;
|
|
175
|
+
function missingDeps(pkg, want) {
|
|
176
|
+
const deps = DEPS_OF(pkg);
|
|
177
|
+
return want.filter((d) => deps[d] === void 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/files.ts
|
|
181
|
+
import { existsSync as existsSync2, mkdirSync, writeFileSync } from "node:fs";
|
|
182
|
+
import { dirname as dirname2, join as join2, normalize } from "node:path";
|
|
183
|
+
async function writeTree(io, cwd, files, opts = {}) {
|
|
184
|
+
for (const f of files) {
|
|
185
|
+
const abs = normalize(join2(cwd, f.path));
|
|
186
|
+
if (!abs.startsWith(normalize(cwd))) {
|
|
187
|
+
io.error(`refusing to write outside the project: ${io.fmt.accent(f.path)}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (existsSync2(abs) && !opts.force) {
|
|
191
|
+
const overwrite = io.interactive ? await io.confirm(`${io.fmt.accent(f.path)} exists \u2014 overwrite?`, false) : false;
|
|
192
|
+
if (!overwrite) {
|
|
193
|
+
io.warn(
|
|
194
|
+
`skipped ${io.fmt.accent(f.path)} (exists \u2014 pass ${io.fmt.accent("--force")} to overwrite)`
|
|
195
|
+
);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
mkdirSync(dirname2(abs), { recursive: true });
|
|
200
|
+
writeFileSync(abs, f.content);
|
|
201
|
+
io.ok(`created ${io.fmt.accent(f.path)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/templates.ts
|
|
206
|
+
var NPMRC = `# Supply-chain hardening \u2014 read by every npm command run in this directory.
|
|
207
|
+
#
|
|
208
|
+
# Newly published versions must age 7 days before they can enter the
|
|
209
|
+
# lockfile (requires npm >= 11.10 \u2014 shipped with Node 26; older npm
|
|
210
|
+
# silently ignores this key).
|
|
211
|
+
min-release-age=7
|
|
212
|
+
# Install lifecycle scripts are the dominant malware vector \u2014 none run
|
|
213
|
+
# during install/ci. If a native dep ever legitimately needs one:
|
|
214
|
+
# npm rebuild <pkg> --ignore-scripts=false
|
|
215
|
+
ignore-scripts=true
|
|
216
|
+
# \`npm install <pkg>\` writes exact pins, not ^ ranges.
|
|
217
|
+
save-exact=true
|
|
218
|
+
fund=false
|
|
219
|
+
`;
|
|
220
|
+
var GITIGNORE = `node_modules/
|
|
221
|
+
dist/
|
|
222
|
+
`;
|
|
223
|
+
function memoryContract(name) {
|
|
224
|
+
const c = camel(name);
|
|
225
|
+
return `import { defineSharedMemory, field, mz } from '@atolljs/core';
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* ${c} shared memory \u2014 the single source of truth for state both threads
|
|
229
|
+
* touch. The spec compiles to one deterministic fixed-width layout; main
|
|
230
|
+
* thread and workers bind the same declaration, so the two sides can never
|
|
231
|
+
* disagree on byte offsets.
|
|
232
|
+
*/
|
|
233
|
+
export const ${c}Memory = defineSharedMemory({
|
|
234
|
+
// Versioned scalars \u2014 observe()/watch() park on Atomics; zero postMessage.
|
|
235
|
+
signals: {
|
|
236
|
+
progress: field.number(),
|
|
237
|
+
},
|
|
238
|
+
// Structured snapshots \u2014 one inline record, rewritten wholesale.
|
|
239
|
+
state: {
|
|
240
|
+
summary: field.object({
|
|
241
|
+
schema: mz.object({
|
|
242
|
+
total: mz.f64(),
|
|
243
|
+
errors: mz.f64(),
|
|
244
|
+
updatedAt: mz.u32(),
|
|
245
|
+
}),
|
|
246
|
+
}),
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
export type ${pascal(name)}Memory = typeof ${c}Memory;
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
function workerEntry(name, opts) {
|
|
253
|
+
const c = camel(name);
|
|
254
|
+
const memImport = opts.memoryImport ? `import { ${opts.memoryName} } from '${opts.memoryImport}';
|
|
255
|
+
` : "";
|
|
256
|
+
const memLine = opts.memoryName ? ` sharedMemory: ${opts.memoryName},
|
|
257
|
+
` : "";
|
|
258
|
+
const shim = opts.host === "node" ? `// Node worker entry \u2014 the shim binds self = parentPort BEFORE
|
|
259
|
+
// defineWorker's bootstrap evaluates; import order is the contract.
|
|
260
|
+
import '@atolljs/node/shim';
|
|
261
|
+
` : "";
|
|
262
|
+
return `${shim}import { defineWorker, serviceMethod } from '@atolljs/core';
|
|
263
|
+
import { z } from 'zod';
|
|
264
|
+
${memImport}
|
|
265
|
+
const echo = serviceMethod({
|
|
266
|
+
def: { argsSchema: z.tuple([z.string()]), resultSchema: z.string() },
|
|
267
|
+
run: (message) => {${opts.demoMemoryWrite && opts.memoryName ? `
|
|
268
|
+
// Shared memory crosses the boundary without a message \u2014 the main
|
|
269
|
+
// thread can read these the moment the task resolves.
|
|
270
|
+
${opts.memoryName}.signals.progress.write(100);
|
|
271
|
+
${opts.memoryName}.state.summary.write({
|
|
272
|
+
total: 1,
|
|
273
|
+
errors: 0,
|
|
274
|
+
updatedAt: Math.floor(Date.now() / 1000),
|
|
275
|
+
});` : ""}
|
|
276
|
+
return \`worker heard: \${message}\`;
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* ${c} worker entry \u2014 defineWorker installs the message loop and registers
|
|
282
|
+
* every method. The main thread imports only the TYPE (typeof ${c}Worker)
|
|
283
|
+
* and drives calls through the pool client.
|
|
284
|
+
*/
|
|
285
|
+
export const ${c}Worker = defineWorker({
|
|
286
|
+
${memLine} methods: { echo },
|
|
287
|
+
});
|
|
288
|
+
export type ${pascal(name)}Worker = typeof ${c}Worker;
|
|
289
|
+
`;
|
|
290
|
+
}
|
|
291
|
+
function workerClientFile(name, opts) {
|
|
292
|
+
const c = camel(name);
|
|
293
|
+
const P = pascal(name);
|
|
294
|
+
const memImport = opts.memoryImport ? `import { ${opts.memoryName} } from '${opts.memoryImport}';
|
|
295
|
+
` : "";
|
|
296
|
+
const memLine = opts.memoryName ? ` sharedMemory: ${opts.memoryName},
|
|
297
|
+
` : "";
|
|
298
|
+
if (opts.host === "node") {
|
|
299
|
+
return `import { Worker } from 'node:worker_threads';
|
|
300
|
+
import { workerClient, defineTask } from '@atolljs/core';
|
|
301
|
+
import { createNodePool } from '@atolljs/node';
|
|
302
|
+
${memImport}import type { ${P}Worker } from './${name}.worker';
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* ${c} pool \u2014 node:worker_threads workers adapted to the pool surface.
|
|
306
|
+
* The worker entry must be bundled before spawning (Node workers run
|
|
307
|
+
* outside tsx's loader):
|
|
308
|
+
*
|
|
309
|
+
* esbuild src/atoll/${name}.worker.ts --bundle --platform=node \\
|
|
310
|
+
* --format=esm --packages=external --outfile=dist/${name}.worker.js
|
|
311
|
+
*/
|
|
312
|
+
export const ${c}Pool = createNodePool({
|
|
313
|
+
worker: () => new Worker(new URL('${opts.distUrl ?? `./dist/${name}.worker.js`}', import.meta.url)),
|
|
314
|
+
${memLine} poolSize: 'auto',
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
/** Typed client \u2014 calls read like the worker's own method names. */
|
|
318
|
+
export const ${c} = workerClient<${P}Worker>(${c}Pool);
|
|
319
|
+
|
|
320
|
+
/** Latest-wins task over a call \u2014 { data, pending, settled, elapsedMs }. */
|
|
321
|
+
export const ${c}EchoTask = defineTask((message: string) => ${c}.echo(message));
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
return `import { connectWorker, defineTask } from '@atolljs/core';
|
|
325
|
+
${memImport}import type { ${P}Worker } from './${name}.worker';
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* ${c} \u2014 typed client over the worker pool. Spawning is lazy, so importing
|
|
329
|
+
* this module never touches Worker (SSR-safe); the first method call (or
|
|
330
|
+
* ${c}.start()) brings the pool up. Keep the new Worker(new URL(...)) call
|
|
331
|
+
* inline \u2014 bundlers detect worker entries by that exact shape.
|
|
332
|
+
*/
|
|
333
|
+
export const ${c} = connectWorker<${P}Worker>({
|
|
334
|
+
worker: () =>
|
|
335
|
+
new Worker(new URL('./${name}.worker.ts', import.meta.url), { type: 'module' }),
|
|
336
|
+
${memLine} poolSize: 'auto',
|
|
337
|
+
});
|
|
338
|
+
export type ${P}Client = typeof ${c};
|
|
339
|
+
|
|
340
|
+
/** Latest-wins task over a call \u2014 { data, pending, settled, elapsedMs }. */
|
|
341
|
+
export const ${c}EchoTask = defineTask((message: string) => ${c}.echo(message));
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
var ISLAND_EXT = {
|
|
345
|
+
react: "tsx",
|
|
346
|
+
vue: "ts",
|
|
347
|
+
solid: "tsx",
|
|
348
|
+
svelte: "ts",
|
|
349
|
+
angular: "ts"
|
|
350
|
+
};
|
|
351
|
+
function islandFiles(name, fw, dir = "src/islands") {
|
|
352
|
+
const P = pascal(name);
|
|
353
|
+
switch (fw) {
|
|
354
|
+
case "react":
|
|
355
|
+
return [
|
|
356
|
+
{
|
|
357
|
+
path: `${dir}/${name}.worker.tsx`,
|
|
358
|
+
content: `import { useState } from 'react';
|
|
359
|
+
import { defineReactMonoWorker } from '@atolljs/react-island/worker';
|
|
360
|
+
import { emit } from '@atolljs/islands/worker';
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* ${P} \u2014 a real React component rendered INSIDE the worker. Mutations
|
|
364
|
+
* serialize to ops; the shell driver replays them into real DOM.
|
|
365
|
+
* Handlers receive the plain wire payload, not a SyntheticEvent.
|
|
366
|
+
*/
|
|
367
|
+
function ${P}({ label = '${name}' }: { label?: string }) {
|
|
368
|
+
const [count, setCount] = useState(0);
|
|
369
|
+
return (
|
|
370
|
+
<div>
|
|
371
|
+
<span>
|
|
372
|
+
{label}: {count}
|
|
373
|
+
</span>
|
|
374
|
+
<button
|
|
375
|
+
onClick={() => {
|
|
376
|
+
const n = count + 1;
|
|
377
|
+
setCount(n);
|
|
378
|
+
emit('incremented', { count: n });
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
increment
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const worker = defineReactMonoWorker(${P});
|
|
388
|
+
`
|
|
389
|
+
}
|
|
390
|
+
];
|
|
391
|
+
case "vue":
|
|
392
|
+
return [
|
|
393
|
+
{
|
|
394
|
+
path: `${dir}/${P}.vue`,
|
|
395
|
+
content: `<script setup lang="ts">
|
|
396
|
+
import { ref } from 'vue';
|
|
397
|
+
import { emit } from '@atolljs/islands/worker';
|
|
398
|
+
|
|
399
|
+
// Rendered inside the worker \u2014 DOM ops stream to the shell; emit()
|
|
400
|
+
// sends app events back to it.
|
|
401
|
+
defineProps<{ label?: string }>();
|
|
402
|
+
const count = ref(0);
|
|
403
|
+
function increment() {
|
|
404
|
+
count.value += 1;
|
|
405
|
+
emit('incremented', { count: count.value });
|
|
406
|
+
}
|
|
407
|
+
</script>
|
|
408
|
+
|
|
409
|
+
<template>
|
|
410
|
+
<div>
|
|
411
|
+
<span>{{ label ?? '${name}' }}: {{ count }}</span>
|
|
412
|
+
<button @click="increment">increment</button>
|
|
413
|
+
</div>
|
|
414
|
+
</template>
|
|
415
|
+
`
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
path: `${dir}/${name}.worker.ts`,
|
|
419
|
+
content: `import { defineVueMonoWorker } from '@atolljs/vue-island/worker';
|
|
420
|
+
import ${P} from './${P}.vue';
|
|
421
|
+
|
|
422
|
+
export const worker = defineVueMonoWorker(${P});
|
|
423
|
+
`
|
|
424
|
+
}
|
|
425
|
+
];
|
|
426
|
+
case "solid":
|
|
427
|
+
return [
|
|
428
|
+
{
|
|
429
|
+
path: `${dir}/${name}.worker.tsx`,
|
|
430
|
+
content: `import { createSignal } from 'solid-js';
|
|
431
|
+
import { defineSolidMonoWorker } from '@atolljs/solid-island/worker';
|
|
432
|
+
import { emit } from '@atolljs/islands/worker';
|
|
433
|
+
|
|
434
|
+
/** ${P} \u2014 Solid rendering inside the worker; ops stream to the shell. */
|
|
435
|
+
function ${P}(props: { label?: string }) {
|
|
436
|
+
const [count, setCount] = createSignal(0);
|
|
437
|
+
return (
|
|
438
|
+
<div>
|
|
439
|
+
<span>
|
|
440
|
+
{props.label ?? '${name}'}: {count()}
|
|
441
|
+
</span>
|
|
442
|
+
<button
|
|
443
|
+
onClick={() => {
|
|
444
|
+
const n = count() + 1;
|
|
445
|
+
setCount(n);
|
|
446
|
+
emit('incremented', { count: n });
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
increment
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export const worker = defineSolidMonoWorker(${P});
|
|
456
|
+
`
|
|
457
|
+
}
|
|
458
|
+
];
|
|
459
|
+
case "svelte":
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
path: `${dir}/${P}.svelte`,
|
|
463
|
+
content: `<script lang="ts">
|
|
464
|
+
import { emit } from '@atolljs/islands/worker';
|
|
465
|
+
|
|
466
|
+
// Rendered inside the worker \u2014 DOM ops stream to the shell.
|
|
467
|
+
let { label = '${name}' }: { label?: string } = $props();
|
|
468
|
+
let count = $state(0);
|
|
469
|
+
function increment() {
|
|
470
|
+
count += 1;
|
|
471
|
+
emit('incremented', { count });
|
|
472
|
+
}
|
|
473
|
+
</script>
|
|
474
|
+
|
|
475
|
+
<div>
|
|
476
|
+
<span>{label}: {count}</span>
|
|
477
|
+
<button onclick={increment}>increment</button>
|
|
478
|
+
</div>
|
|
479
|
+
`
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
path: `${dir}/${name}.worker.ts`,
|
|
483
|
+
content: `import { defineSvelteMonoWorker } from '@atolljs/svelte-island/worker';
|
|
484
|
+
import ${P} from './${P}.svelte';
|
|
485
|
+
|
|
486
|
+
export const worker = defineSvelteMonoWorker(${P});
|
|
487
|
+
`
|
|
488
|
+
}
|
|
489
|
+
];
|
|
490
|
+
case "angular":
|
|
491
|
+
return [
|
|
492
|
+
{
|
|
493
|
+
path: `${dir}/${name}.worker.ts`,
|
|
494
|
+
content: `import { Component } from '@angular/core';
|
|
495
|
+
import { defineMonoWorker } from '@atolljs/islands/worker';
|
|
496
|
+
import { angularIslandApp } from '@atolljs/angular-island/worker';
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* ${P}Component \u2014 rendered inside the worker by Angular's own engine;
|
|
500
|
+
* ops stream to the shell driver on the main thread.
|
|
501
|
+
*/
|
|
502
|
+
@Component({
|
|
503
|
+
template: \`
|
|
504
|
+
<div>
|
|
505
|
+
<span>{{ label }}: {{ count }}</span>
|
|
506
|
+
<button (click)="increment()">increment</button>
|
|
507
|
+
</div>
|
|
508
|
+
\`,
|
|
509
|
+
})
|
|
510
|
+
export class ${P}Component {
|
|
511
|
+
label = '${name}';
|
|
512
|
+
count = 0;
|
|
513
|
+
increment() {
|
|
514
|
+
this.count += 1;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export const worker = defineMonoWorker(angularIslandApp(${P}Component));
|
|
519
|
+
`
|
|
520
|
+
}
|
|
521
|
+
];
|
|
522
|
+
default:
|
|
523
|
+
return [];
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function islandUsage(name, fw) {
|
|
527
|
+
const P = pascal(name);
|
|
528
|
+
const ext = ISLAND_EXT[fw] ?? "ts";
|
|
529
|
+
const factory = `() => new Worker(new URL('./islands/${name}.worker.${ext}', import.meta.url), { type: 'module' })`;
|
|
530
|
+
switch (fw) {
|
|
531
|
+
case "react":
|
|
532
|
+
return `// mount it in your shell \u2014 worker factory stays inline so the bundler sees it
|
|
533
|
+
import { Island } from '@atolljs/react-island';
|
|
534
|
+
|
|
535
|
+
<Island app="${name}" worker={${factory}} onEvent={(n, p) => console.log(n, p)} />`;
|
|
536
|
+
case "vue":
|
|
537
|
+
return `<script setup>
|
|
538
|
+
import { AtollIsland } from '@atolljs/vue-island';
|
|
539
|
+
const worker = ${factory};
|
|
540
|
+
</script>
|
|
541
|
+
|
|
542
|
+
<template>
|
|
543
|
+
<AtollIsland app="${name}" :worker="worker" />
|
|
544
|
+
</template>`;
|
|
545
|
+
case "solid":
|
|
546
|
+
return `import { Island } from '@atolljs/solid-island';
|
|
547
|
+
|
|
548
|
+
<Island app="${name}" worker={${factory}} />`;
|
|
549
|
+
case "svelte":
|
|
550
|
+
return `<script lang="ts">
|
|
551
|
+
import { island } from '@atolljs/svelte-island';
|
|
552
|
+
const worker = ${factory};
|
|
553
|
+
</script>
|
|
554
|
+
|
|
555
|
+
<div use:island={{ app: '${name}', worker }} />`;
|
|
556
|
+
case "angular":
|
|
557
|
+
return `import { islandComponent } from '@atolljs/angular-island';
|
|
558
|
+
import type { ${P}Component } from './islands/${name}.worker';
|
|
559
|
+
|
|
560
|
+
export const ${P}Island = islandComponent<${P}Component>({
|
|
561
|
+
app: '${name}',
|
|
562
|
+
worker: ${factory},
|
|
563
|
+
});
|
|
564
|
+
// then use the generated component in a template \u2014 see docs/consumer Angular islands`;
|
|
565
|
+
default:
|
|
566
|
+
return "";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
var HEADERS = ` // SharedArrayBuffer is gated behind cross-origin isolation \u2014 these
|
|
570
|
+
// headers unlock it; islands/pools degrade gracefully without them.
|
|
571
|
+
server: {
|
|
572
|
+
headers: {
|
|
573
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
574
|
+
'Cross-Origin-Embedder-Policy': 'require-corp',
|
|
575
|
+
},
|
|
576
|
+
},`;
|
|
577
|
+
var SCAFFOLDS = {
|
|
578
|
+
react: {
|
|
579
|
+
deps: { react: "^19.3.0", "react-dom": "^19.3.0", "react-reconciler": "^0.34.0", "@atolljs/core": "^0.1.0", "@atolljs/react": "^0.1.0", "@atolljs/react-island": "^0.1.0", zod: "^4.6.5" },
|
|
580
|
+
devDeps: { vite: "^8.3.1", typescript: "^7.0.2", "@vitejs/plugin-react": "^6.1.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0" },
|
|
581
|
+
vitePlugin: { pkg: "@vitejs/plugin-react", import_: `import react from '@vitejs/plugin-react';`, call: "react()" }
|
|
582
|
+
},
|
|
583
|
+
vue: {
|
|
584
|
+
deps: { vue: "^3.5.43", "@atolljs/core": "^0.1.0", "@atolljs/vue": "^0.1.0", "@atolljs/vue-island": "^0.1.0", zod: "^4.6.5" },
|
|
585
|
+
devDeps: { vite: "^8.3.1", typescript: "^7.0.2", "@vitejs/plugin-vue": "^6.0.9" },
|
|
586
|
+
vitePlugin: { pkg: "@vitejs/plugin-vue", import_: `import vue from '@vitejs/plugin-vue';`, call: "vue()" }
|
|
587
|
+
},
|
|
588
|
+
solid: {
|
|
589
|
+
deps: { "solid-js": "^1.9.15", "@atolljs/core": "^0.1.0", "@atolljs/solidjs": "^0.1.0", "@atolljs/solid-island": "^0.1.0", zod: "^4.6.5" },
|
|
590
|
+
devDeps: { vite: "^8.3.1", typescript: "^7.0.2", "vite-plugin-solid": "^2.11.14" },
|
|
591
|
+
vitePlugin: { pkg: "vite-plugin-solid", import_: `import solid from 'vite-plugin-solid';`, call: "solid()" }
|
|
592
|
+
},
|
|
593
|
+
svelte: {
|
|
594
|
+
deps: { svelte: "^5.57.1", "@atolljs/core": "^0.1.0", "@atolljs/svelte": "^0.1.0", "@atolljs/svelte-island": "^0.1.0", zod: "^4.6.5" },
|
|
595
|
+
devDeps: { vite: "^8.3.1", typescript: "^7.0.2", "@sveltejs/vite-plugin-svelte": "^7.3.1" },
|
|
596
|
+
vitePlugin: { pkg: "@sveltejs/vite-plugin-svelte", import_: `import { svelte } from '@sveltejs/vite-plugin-svelte';`, call: "svelte()" }
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
var SCAFFOLD_FRAMEWORKS = Object.keys(SCAFFOLDS);
|
|
600
|
+
function pkgJson(appName, spec) {
|
|
601
|
+
return JSON.stringify(
|
|
602
|
+
{
|
|
603
|
+
name: appName,
|
|
604
|
+
private: true,
|
|
605
|
+
type: "module",
|
|
606
|
+
scripts: { dev: "vite", build: "vite build", preview: "vite preview" },
|
|
607
|
+
dependencies: spec.deps,
|
|
608
|
+
devDependencies: spec.devDeps
|
|
609
|
+
},
|
|
610
|
+
null,
|
|
611
|
+
2
|
|
612
|
+
) + "\n";
|
|
613
|
+
}
|
|
614
|
+
var TSCONFIGS = {
|
|
615
|
+
react: {
|
|
616
|
+
compilerOptions: {
|
|
617
|
+
target: "ES2022",
|
|
618
|
+
module: "ESNext",
|
|
619
|
+
moduleResolution: "bundler",
|
|
620
|
+
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
|
621
|
+
jsx: "react-jsx",
|
|
622
|
+
strict: true,
|
|
623
|
+
noEmit: true,
|
|
624
|
+
isolatedModules: true,
|
|
625
|
+
skipLibCheck: true,
|
|
626
|
+
allowImportingTsExtensions: true
|
|
627
|
+
},
|
|
628
|
+
include: ["src"]
|
|
629
|
+
},
|
|
630
|
+
vue: {
|
|
631
|
+
compilerOptions: {
|
|
632
|
+
target: "ES2022",
|
|
633
|
+
module: "ESNext",
|
|
634
|
+
moduleResolution: "bundler",
|
|
635
|
+
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
|
636
|
+
strict: true,
|
|
637
|
+
noEmit: true,
|
|
638
|
+
isolatedModules: true,
|
|
639
|
+
skipLibCheck: true,
|
|
640
|
+
allowImportingTsExtensions: true
|
|
641
|
+
},
|
|
642
|
+
include: ["src"]
|
|
643
|
+
},
|
|
644
|
+
solid: {
|
|
645
|
+
compilerOptions: {
|
|
646
|
+
target: "ES2022",
|
|
647
|
+
module: "ESNext",
|
|
648
|
+
moduleResolution: "bundler",
|
|
649
|
+
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
|
650
|
+
jsx: "preserve",
|
|
651
|
+
jsxImportSource: "solid-js",
|
|
652
|
+
strict: true,
|
|
653
|
+
noEmit: true,
|
|
654
|
+
isolatedModules: true,
|
|
655
|
+
skipLibCheck: true,
|
|
656
|
+
allowImportingTsExtensions: true
|
|
657
|
+
},
|
|
658
|
+
include: ["src"]
|
|
659
|
+
},
|
|
660
|
+
svelte: {
|
|
661
|
+
compilerOptions: {
|
|
662
|
+
target: "ES2022",
|
|
663
|
+
module: "ESNext",
|
|
664
|
+
moduleResolution: "bundler",
|
|
665
|
+
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
|
666
|
+
strict: true,
|
|
667
|
+
noEmit: true,
|
|
668
|
+
isolatedModules: true,
|
|
669
|
+
skipLibCheck: true,
|
|
670
|
+
allowImportingTsExtensions: true,
|
|
671
|
+
types: ["vite/client"]
|
|
672
|
+
},
|
|
673
|
+
include: ["src"]
|
|
674
|
+
},
|
|
675
|
+
node: {
|
|
676
|
+
compilerOptions: {
|
|
677
|
+
target: "ES2022",
|
|
678
|
+
module: "ESNext",
|
|
679
|
+
moduleResolution: "bundler",
|
|
680
|
+
// DOM+WebWorker libs — @atolljs/node types pool workers against the
|
|
681
|
+
// DOM Worker surface; without them its shipped source won't typecheck.
|
|
682
|
+
lib: ["ESNext", "DOM", "WebWorker"],
|
|
683
|
+
types: ["node"],
|
|
684
|
+
strict: true,
|
|
685
|
+
noEmit: true,
|
|
686
|
+
isolatedModules: true,
|
|
687
|
+
skipLibCheck: true,
|
|
688
|
+
allowImportingTsExtensions: true
|
|
689
|
+
},
|
|
690
|
+
include: ["src"]
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
function viteConfig(fw) {
|
|
694
|
+
const spec = SCAFFOLDS[fw];
|
|
695
|
+
const plugin = spec.vitePlugin;
|
|
696
|
+
return `import { defineConfig } from 'vite';
|
|
697
|
+
${plugin?.import_ ?? ""}
|
|
698
|
+
|
|
699
|
+
export default defineConfig({
|
|
700
|
+
plugins: [${plugin?.call ?? ""}],
|
|
701
|
+
${plugin ? ` // Worker bundles run their own rolldown pass \u2014 fresh plugin instances
|
|
702
|
+
// let the worker entry compile framework files too (worker-island apps).
|
|
703
|
+
worker: { plugins: () => [${plugin.call}] },
|
|
704
|
+
` : ""}${HEADERS}
|
|
705
|
+
});
|
|
706
|
+
`;
|
|
707
|
+
}
|
|
708
|
+
var INDEX_HTML = (title, entry) => `<!doctype html>
|
|
709
|
+
<html lang="en">
|
|
710
|
+
<head>
|
|
711
|
+
<meta charset="UTF-8" />
|
|
712
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
713
|
+
<title>${title}</title>
|
|
714
|
+
</head>
|
|
715
|
+
<body>
|
|
716
|
+
<div id="root"></div>
|
|
717
|
+
<script type="module" src="/${entry}"></script>
|
|
718
|
+
</body>
|
|
719
|
+
</html>
|
|
720
|
+
`;
|
|
721
|
+
function scaffoldFiles(appName, fw) {
|
|
722
|
+
const spec = SCAFFOLDS[fw];
|
|
723
|
+
if (!spec) return [];
|
|
724
|
+
const files = [
|
|
725
|
+
{ path: "package.json", content: pkgJson(appName, spec) },
|
|
726
|
+
{ path: "tsconfig.json", content: JSON.stringify(TSCONFIGS[fw], null, 2) + "\n" },
|
|
727
|
+
{ path: "vite.config.ts", content: viteConfig(fw) },
|
|
728
|
+
{ path: ".npmrc", content: NPMRC },
|
|
729
|
+
{ path: ".gitignore", content: GITIGNORE },
|
|
730
|
+
{ path: "index.html", content: INDEX_HTML(appName, `src/main.${ISLAND_EXT[fw]}`) }
|
|
731
|
+
];
|
|
732
|
+
const counter = islandFiles("counter", fw);
|
|
733
|
+
switch (fw) {
|
|
734
|
+
case "react":
|
|
735
|
+
files.push(
|
|
736
|
+
...counter,
|
|
737
|
+
{
|
|
738
|
+
path: "src/main.tsx",
|
|
739
|
+
content: `import { createRoot } from 'react-dom/client';
|
|
740
|
+
import { App } from './App';
|
|
741
|
+
|
|
742
|
+
createRoot(document.getElementById('root')!).render(<App />);
|
|
743
|
+
`
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
path: "src/App.tsx",
|
|
747
|
+
content: `import { Island } from '@atolljs/react-island';
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* The counter island renders inside a worker \u2014 the main thread only replays
|
|
751
|
+
* its DOM ops. New islands: \`npx atoll add island <name>\`.
|
|
752
|
+
*/
|
|
753
|
+
export function App() {
|
|
754
|
+
return (
|
|
755
|
+
<main style={{ fontFamily: 'system-ui', padding: '2rem' }}>
|
|
756
|
+
<h1>${appName}</h1>
|
|
757
|
+
<p>This counter renders in a worker thread.</p>
|
|
758
|
+
<Island
|
|
759
|
+
app="counter"
|
|
760
|
+
worker={() =>
|
|
761
|
+
new Worker(new URL('./islands/counter.worker.tsx', import.meta.url), { type: 'module' })
|
|
762
|
+
}
|
|
763
|
+
onEvent={(name, payload) => console.log('island event:', name, payload)}
|
|
764
|
+
/>
|
|
765
|
+
</main>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
`
|
|
769
|
+
}
|
|
770
|
+
);
|
|
771
|
+
break;
|
|
772
|
+
case "vue":
|
|
773
|
+
files.push(
|
|
774
|
+
...counter,
|
|
775
|
+
{ path: "src/env.d.ts", content: `declare module '*.vue';
|
|
776
|
+
` },
|
|
777
|
+
{
|
|
778
|
+
path: "src/main.ts",
|
|
779
|
+
content: `import { createApp } from 'vue';
|
|
780
|
+
import App from './App.vue';
|
|
781
|
+
|
|
782
|
+
createApp(App).mount('#root');
|
|
783
|
+
`
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
path: "src/App.vue",
|
|
787
|
+
content: `<script setup lang="ts">
|
|
788
|
+
import { AtollIsland } from '@atolljs/vue-island';
|
|
789
|
+
|
|
790
|
+
// The counter island renders inside a worker \u2014 the main thread replays ops.
|
|
791
|
+
const worker = () =>
|
|
792
|
+
new Worker(new URL('./islands/counter.worker.ts', import.meta.url), { type: 'module' });
|
|
793
|
+
</script>
|
|
794
|
+
|
|
795
|
+
<template>
|
|
796
|
+
<main style="font-family: system-ui; padding: 2rem">
|
|
797
|
+
<h1>${appName}</h1>
|
|
798
|
+
<p>This counter renders in a worker thread.</p>
|
|
799
|
+
<AtollIsland app="counter" :worker="worker" />
|
|
800
|
+
</main>
|
|
801
|
+
</template>
|
|
802
|
+
`
|
|
803
|
+
}
|
|
804
|
+
);
|
|
805
|
+
break;
|
|
806
|
+
case "solid":
|
|
807
|
+
files.push(
|
|
808
|
+
...counter,
|
|
809
|
+
{
|
|
810
|
+
path: "src/main.tsx",
|
|
811
|
+
content: `import { render } from 'solid-js/web';
|
|
812
|
+
import { App } from './App';
|
|
813
|
+
|
|
814
|
+
render(() => <App />, document.getElementById('root')!);
|
|
815
|
+
`
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
path: "src/App.tsx",
|
|
819
|
+
content: `import { Island } from '@atolljs/solid-island';
|
|
820
|
+
|
|
821
|
+
/** The counter island renders inside a worker \u2014 the main thread replays ops. */
|
|
822
|
+
export function App() {
|
|
823
|
+
return (
|
|
824
|
+
<main style={{ 'font-family': 'system-ui', padding: '2rem' }}>
|
|
825
|
+
<h1>${appName}</h1>
|
|
826
|
+
<p>This counter renders in a worker thread.</p>
|
|
827
|
+
<Island
|
|
828
|
+
app="counter"
|
|
829
|
+
worker={() =>
|
|
830
|
+
new Worker(new URL('./islands/counter.worker.tsx', import.meta.url), { type: 'module' })
|
|
831
|
+
}
|
|
832
|
+
/>
|
|
833
|
+
</main>
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
`
|
|
837
|
+
}
|
|
838
|
+
);
|
|
839
|
+
break;
|
|
840
|
+
case "svelte":
|
|
841
|
+
files.push(
|
|
842
|
+
...counter,
|
|
843
|
+
{
|
|
844
|
+
path: "src/main.ts",
|
|
845
|
+
content: `import { mount } from 'svelte';
|
|
846
|
+
import App from './App.svelte';
|
|
847
|
+
|
|
848
|
+
mount(App, { target: document.getElementById('root')! });
|
|
849
|
+
`
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
path: "src/App.svelte",
|
|
853
|
+
content: `<script lang="ts">
|
|
854
|
+
import { island } from '@atolljs/svelte-island';
|
|
855
|
+
|
|
856
|
+
// The counter island renders inside a worker \u2014 the main thread replays ops.
|
|
857
|
+
const worker = () =>
|
|
858
|
+
new Worker(new URL('./islands/counter.worker.ts', import.meta.url), { type: 'module' });
|
|
859
|
+
</script>
|
|
860
|
+
|
|
861
|
+
<main style="font-family: system-ui; padding: 2rem">
|
|
862
|
+
<h1>${appName}</h1>
|
|
863
|
+
<p>This counter renders in a worker thread.</p>
|
|
864
|
+
<div use:island={{ app: 'counter', worker }} />
|
|
865
|
+
</main>
|
|
866
|
+
`
|
|
867
|
+
}
|
|
868
|
+
);
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
return files;
|
|
872
|
+
}
|
|
873
|
+
function nodeScaffoldFiles(appName) {
|
|
874
|
+
return [
|
|
875
|
+
{
|
|
876
|
+
path: "package.json",
|
|
877
|
+
content: JSON.stringify(
|
|
878
|
+
{
|
|
879
|
+
name: appName,
|
|
880
|
+
private: true,
|
|
881
|
+
type: "module",
|
|
882
|
+
scripts: {
|
|
883
|
+
dev: "tsx watch src/main.ts",
|
|
884
|
+
start: "tsx src/main.ts",
|
|
885
|
+
bundle: "esbuild src/atoll/tasks.worker.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/tasks.worker.js"
|
|
886
|
+
},
|
|
887
|
+
dependencies: { "@atolljs/core": "^0.1.0", "@atolljs/node": "^0.1.0", zod: "^4.6.5" },
|
|
888
|
+
devDependencies: { esbuild: "^0.28.2", tsx: "^4.23.15", typescript: "^7.0.2", "@types/node": "^26.6.3" }
|
|
889
|
+
},
|
|
890
|
+
null,
|
|
891
|
+
2
|
|
892
|
+
) + "\n"
|
|
893
|
+
},
|
|
894
|
+
{ path: "tsconfig.json", content: JSON.stringify(TSCONFIGS.node, null, 2) + "\n" },
|
|
895
|
+
{ path: ".npmrc", content: NPMRC },
|
|
896
|
+
{ path: ".gitignore", content: GITIGNORE },
|
|
897
|
+
{
|
|
898
|
+
path: "src/atoll/tasks.memory.ts",
|
|
899
|
+
content: memoryContract("tasks")
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
path: "src/atoll/tasks.worker.ts",
|
|
903
|
+
content: workerEntry("tasks", {
|
|
904
|
+
host: "node",
|
|
905
|
+
memoryImport: "./tasks.memory",
|
|
906
|
+
memoryName: "tasksMemory",
|
|
907
|
+
demoMemoryWrite: true
|
|
908
|
+
})
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
path: "src/atoll/tasks.ts",
|
|
912
|
+
content: workerClientFile("tasks", {
|
|
913
|
+
host: "node",
|
|
914
|
+
memoryImport: "./tasks.memory",
|
|
915
|
+
memoryName: "tasksMemory",
|
|
916
|
+
distUrl: "../../dist/tasks.worker.js"
|
|
917
|
+
})
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
path: "src/main.ts",
|
|
921
|
+
content: `import { tasks, tasksPool } from './atoll/tasks';
|
|
922
|
+
import { tasksMemory } from './atoll/tasks.memory';
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* ${appName} \u2014 a worker pool behind a typed client. The echo task runs off
|
|
926
|
+
* the main thread; shared memory is readable here with zero dispatch.
|
|
927
|
+
*/
|
|
928
|
+
const heard = await tasks.echo('hello from main');
|
|
929
|
+
console.log(heard);
|
|
930
|
+
console.log('summary (shared memory):', tasksMemory.state.summary.read());
|
|
931
|
+
|
|
932
|
+
await tasksPool.close();
|
|
933
|
+
`
|
|
934
|
+
}
|
|
935
|
+
];
|
|
936
|
+
}
|
|
937
|
+
function workerBindingsFile(name, fw, dir, mem) {
|
|
938
|
+
const c = camel(name);
|
|
939
|
+
const P = pascal(name);
|
|
940
|
+
const memImport = mem ? `import { ${mem.export_} } from '${mem.import_}';
|
|
941
|
+
` : "";
|
|
942
|
+
const watchLine = (hook, decl = "const") => mem ? `
|
|
943
|
+
// ${decl} progress = ${hook}(${mem.export_}, 'signals.progress');` : "";
|
|
944
|
+
switch (fw) {
|
|
945
|
+
case "react":
|
|
946
|
+
return {
|
|
947
|
+
path: `${dir}/use${P}.ts`,
|
|
948
|
+
content: `import { useTask${mem ? ", useSharedValue" : ""} } from '@atolljs/react';
|
|
949
|
+
import { ${c} } from './${name}';
|
|
950
|
+
${memImport}
|
|
951
|
+
/**
|
|
952
|
+
* React bindings for the ${name} pool \u2014 call inside components. useTask
|
|
953
|
+
* wraps a call in { data, pending, settled, elapsedMs, error } with
|
|
954
|
+
* latest-wins run()/runOnce(); useSharedValue parks on Atomics.
|
|
955
|
+
*/
|
|
956
|
+
export function use${P}() {
|
|
957
|
+
const echo = useTask(${c}.echo);${watchLine("useSharedValue")}
|
|
958
|
+
return { echo };
|
|
959
|
+
}
|
|
960
|
+
`
|
|
961
|
+
};
|
|
962
|
+
case "nextjs":
|
|
963
|
+
return {
|
|
964
|
+
path: `${dir}/${name}.tsx`,
|
|
965
|
+
content: `'use client';
|
|
966
|
+
import { useTask${mem ? ", useSharedValue" : ""} } from '@atolljs/nextjs';
|
|
967
|
+
import { ${c} } from './${name}';
|
|
968
|
+
${memImport}
|
|
969
|
+
/**
|
|
970
|
+
* Client component over the ${name} pool \u2014 'use client' keeps the hooks
|
|
971
|
+
* browser-side; SSR-safe because the pool spawns lazily on first call.
|
|
972
|
+
*/
|
|
973
|
+
export function ${P}() {
|
|
974
|
+
const echo = useTask(${c}.echo);${watchLine("useSharedValue")}
|
|
975
|
+
return (
|
|
976
|
+
<button onClick={() => echo.run('ping')}>
|
|
977
|
+
{echo.data ?? (echo.pending ? 'working\\u2026' : '${name}')}
|
|
978
|
+
</button>
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
`
|
|
982
|
+
};
|
|
983
|
+
case "vue":
|
|
984
|
+
return {
|
|
985
|
+
path: `${dir}/use${P}.ts`,
|
|
986
|
+
content: `import { useTask${mem ? ", useSharedValue" : ""} } from '@atolljs/vue';
|
|
987
|
+
import { ${c} } from './${name}';
|
|
988
|
+
${memImport}
|
|
989
|
+
/** Vue composable over the ${name} pool \u2014 refs, ready for <script setup>. */
|
|
990
|
+
export function use${P}() {
|
|
991
|
+
const echo = useTask(${c}.echo);${watchLine("useSharedValue")}
|
|
992
|
+
return { echo };
|
|
993
|
+
}
|
|
994
|
+
`
|
|
995
|
+
};
|
|
996
|
+
case "solid":
|
|
997
|
+
return {
|
|
998
|
+
path: `${dir}/use${P}.ts`,
|
|
999
|
+
content: `import { createTask${mem ? ", createSharedValue" : ""} } from '@atolljs/solidjs';
|
|
1000
|
+
import { ${c} } from './${name}';
|
|
1001
|
+
${memImport}
|
|
1002
|
+
/** Solid bindings over the ${name} pool \u2014 accessors, call inside setup. */
|
|
1003
|
+
export function create${P}() {
|
|
1004
|
+
const echo = createTask(${c}.echo);${watchLine("createSharedValue")}
|
|
1005
|
+
return { echo };
|
|
1006
|
+
}
|
|
1007
|
+
`
|
|
1008
|
+
};
|
|
1009
|
+
case "svelte":
|
|
1010
|
+
return {
|
|
1011
|
+
path: `${dir}/${name}.svelte.ts`,
|
|
1012
|
+
content: `import { taskState${mem ? ", sharedValue" : ""} } from '@atolljs/svelte';
|
|
1013
|
+
import { ${c} } from './${name}';
|
|
1014
|
+
${memImport}
|
|
1015
|
+
/** Svelte bindings over the ${name} pool \u2014 call inside components. */
|
|
1016
|
+
export function ${c}State() {
|
|
1017
|
+
const echo = taskState(${c}.echo);${watchLine("sharedValue")}
|
|
1018
|
+
return { echo };
|
|
1019
|
+
}
|
|
1020
|
+
`
|
|
1021
|
+
};
|
|
1022
|
+
case "angular":
|
|
1023
|
+
return {
|
|
1024
|
+
path: `${dir}/${name}.facade.ts`,
|
|
1025
|
+
content: `import { Injectable } from '@angular/core';
|
|
1026
|
+
import { taskState${mem ? ", sharedValue" : ""} } from '@atolljs/angular';
|
|
1027
|
+
import { ${c} } from './${name}';
|
|
1028
|
+
${memImport}
|
|
1029
|
+
/**
|
|
1030
|
+
* Angular facade over the ${name} pool \u2014 field initializers run in the
|
|
1031
|
+
* injection context, so the signal subscription unbinds on destroy.
|
|
1032
|
+
*/
|
|
1033
|
+
@Injectable({ providedIn: 'root' })
|
|
1034
|
+
export class ${P}Facade {
|
|
1035
|
+
readonly echo = taskState(${c}.echo);${watchLine("sharedValue", "readonly")}
|
|
1036
|
+
readonly run = (message: string) => this.echo.run(message);
|
|
1037
|
+
}
|
|
1038
|
+
`
|
|
1039
|
+
};
|
|
1040
|
+
default:
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
function islandFacadeFiles(name, fw, dir = "src/islands") {
|
|
1045
|
+
const P = pascal(name);
|
|
1046
|
+
const ext = ISLAND_EXT[fw] ?? "ts";
|
|
1047
|
+
const contract = (appLine) => `/**
|
|
1048
|
+
* ${name} island contract \u2014 shell-safe: the app reference + the worker
|
|
1049
|
+
* factory. Never import ./${name}.worker.${ext} here \u2014 definePolyWorker
|
|
1050
|
+
* installs worker-side globals on evaluation.
|
|
1051
|
+
*/
|
|
1052
|
+
${appLine}
|
|
1053
|
+
export const worker = () =>
|
|
1054
|
+
new Worker(new URL('./${name}.worker.${ext}', import.meta.url), { type: 'module' });
|
|
1055
|
+
`;
|
|
1056
|
+
switch (fw) {
|
|
1057
|
+
case "react":
|
|
1058
|
+
case "solid": {
|
|
1059
|
+
const isReact = fw === "react";
|
|
1060
|
+
const sig = isReact ? "useState" : "createSignal";
|
|
1061
|
+
const poly = isReact ? "defineReactPolyWorker" : "defineSolidPolyWorker";
|
|
1062
|
+
const pkg = `@atolljs/${fw}-island/worker`;
|
|
1063
|
+
return [
|
|
1064
|
+
{
|
|
1065
|
+
path: `${dir}/${name}.app.tsx`,
|
|
1066
|
+
content: `import { ${sig} } from '${isReact ? "react" : "solid-js"}';
|
|
1067
|
+
import { islandApp } from '@atolljs/islands/worker';
|
|
1068
|
+
import { emit } from '${pkg}';
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* ${P}App \u2014 a real ${isReact ? "React" : "Solid"} component rendered INSIDE
|
|
1072
|
+
* the worker. islandApp stamps the registry key (minification-proof); state
|
|
1073
|
+
* never leaves the worker, DOM ops stream to the shell driver.
|
|
1074
|
+
*/
|
|
1075
|
+
export const ${P}App = islandApp('${name}', function ${P}(${isReact ? `{ label = '${name}' }: { label?: string }` : `props: { label?: string }`}) {
|
|
1076
|
+
${isReact ? "const [count, setCount] = useState(0);" : "const [count, setCount] = createSignal(0);"}
|
|
1077
|
+
return (
|
|
1078
|
+
<button
|
|
1079
|
+
onClick={() => {
|
|
1080
|
+
const n = ${isReact ? "count" : "count()"} + 1;
|
|
1081
|
+
setCount(n);
|
|
1082
|
+
emit('incremented', { count: n });
|
|
1083
|
+
}}
|
|
1084
|
+
>
|
|
1085
|
+
${isReact ? `{label}: {count}` : `{props.label ?? '${name}'}: {count()}`}
|
|
1086
|
+
</button>
|
|
1087
|
+
);
|
|
1088
|
+
});
|
|
1089
|
+
`
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
path: `${dir}/${name}.worker.${ext}`,
|
|
1093
|
+
content: `import { ${poly} } from '${pkg}';
|
|
1094
|
+
import { ${P}App } from './${name}.app';
|
|
1095
|
+
|
|
1096
|
+
/** One worker, many islands \u2014 add apps to the registry. */
|
|
1097
|
+
export const worker = ${poly}({ apps: { ${name}: ${P}App } });
|
|
1098
|
+
`
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
path: `${dir}/${name}.island.ts`,
|
|
1102
|
+
content: contract(`export { ${P}App as app } from './${name}.app';`)
|
|
1103
|
+
}
|
|
1104
|
+
];
|
|
1105
|
+
}
|
|
1106
|
+
case "vue": {
|
|
1107
|
+
const [component] = islandFiles(name, "vue", dir);
|
|
1108
|
+
return [
|
|
1109
|
+
component,
|
|
1110
|
+
{
|
|
1111
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1112
|
+
content: `import { defineVuePolyWorker } from '@atolljs/vue-island/worker';
|
|
1113
|
+
import ${P} from './${P}.vue';
|
|
1114
|
+
|
|
1115
|
+
/** One worker, many islands \u2014 add apps to the registry. */
|
|
1116
|
+
export const worker = defineVuePolyWorker({ apps: { ${name}: ${P} } });
|
|
1117
|
+
`
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
path: `${dir}/${name}.island.ts`,
|
|
1121
|
+
content: contract(`export const app = '${name}';`)
|
|
1122
|
+
}
|
|
1123
|
+
];
|
|
1124
|
+
}
|
|
1125
|
+
case "svelte": {
|
|
1126
|
+
const [component] = islandFiles(name, "svelte", dir);
|
|
1127
|
+
return [
|
|
1128
|
+
component,
|
|
1129
|
+
{
|
|
1130
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1131
|
+
content: `import { defineSveltePolyWorker } from '@atolljs/svelte-island/worker';
|
|
1132
|
+
import ${P} from './${P}.svelte';
|
|
1133
|
+
|
|
1134
|
+
/** One worker, many islands \u2014 add apps to the registry. */
|
|
1135
|
+
export const worker = defineSveltePolyWorker({ apps: { ${name}: ${P} } });
|
|
1136
|
+
`
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
path: `${dir}/${name}.island.ts`,
|
|
1140
|
+
content: contract(`export const app = '${name}';`)
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
path: `${dir}/${P}Island.svelte`,
|
|
1144
|
+
content: `<script lang="ts">
|
|
1145
|
+
import { island } from '@atolljs/svelte-island';
|
|
1146
|
+
import { app, worker } from './${name}.island';
|
|
1147
|
+
|
|
1148
|
+
// The facade component \u2014 attrs serialize to worker props on mount.
|
|
1149
|
+
let { label = '${name}' }: { label?: string } = $props();
|
|
1150
|
+
</script>
|
|
1151
|
+
|
|
1152
|
+
<div use:island={{ app, worker, props: { label } }} />
|
|
1153
|
+
`
|
|
1154
|
+
}
|
|
1155
|
+
];
|
|
1156
|
+
}
|
|
1157
|
+
case "angular":
|
|
1158
|
+
return [
|
|
1159
|
+
{
|
|
1160
|
+
path: `${dir}/${name}.component.ts`,
|
|
1161
|
+
content: `import { Component, input, output } from '@angular/core';
|
|
1162
|
+
import { AngularIsland } from '@atolljs/angular-island/worker';
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* ${P}Component \u2014 rendered inside the worker by Angular's own engine.
|
|
1166
|
+
* @AngularIsland stamps the registry key and registers the component, so
|
|
1167
|
+
* defineAngularPolyWorker() collects it with no hand-kept apps map.
|
|
1168
|
+
*/
|
|
1169
|
+
@AngularIsland('${name}')
|
|
1170
|
+
@Component({
|
|
1171
|
+
standalone: true,
|
|
1172
|
+
template: \`
|
|
1173
|
+
<button (click)="increment()">{{ label() }}: {{ count }}</button>
|
|
1174
|
+
\`,
|
|
1175
|
+
})
|
|
1176
|
+
export class ${P}Component {
|
|
1177
|
+
label = input('${name}');
|
|
1178
|
+
/** Root output() fields bridge to the shell's island event channel. */
|
|
1179
|
+
incremented = output<{ count: number }>();
|
|
1180
|
+
count = 0;
|
|
1181
|
+
increment() {
|
|
1182
|
+
this.count += 1;
|
|
1183
|
+
this.incremented.emit({ count: this.count });
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
`
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1190
|
+
content: `import { defineAngularPolyWorker } from '@atolljs/angular-island/worker';
|
|
1191
|
+
import './${name}.component'; // registers via @AngularIsland
|
|
1192
|
+
|
|
1193
|
+
/** One worker, many islands \u2014 decorated components self-register. */
|
|
1194
|
+
export const worker = defineAngularPolyWorker();
|
|
1195
|
+
`
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
path: `${dir}/${name}.island.ts`,
|
|
1199
|
+
content: `import { islandComponent } from '@atolljs/angular-island';
|
|
1200
|
+
import type { ${P}Component } from './${name}.component';
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* ${name} island facade \u2014 a standalone component typed off the worker
|
|
1204
|
+
* component's input()/output() contract. Use <${name}-island> in templates.
|
|
1205
|
+
*/
|
|
1206
|
+
export const ${P}Island = islandComponent<${P}Component>({
|
|
1207
|
+
app: '${name}',
|
|
1208
|
+
selector: '${name}-island',
|
|
1209
|
+
worker: () =>
|
|
1210
|
+
new Worker(new URL('./${name}.worker.ts', import.meta.url), { type: 'module' }),
|
|
1211
|
+
});
|
|
1212
|
+
`
|
|
1213
|
+
}
|
|
1214
|
+
];
|
|
1215
|
+
default:
|
|
1216
|
+
return [];
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
function islandFacadeUsage(name, fw) {
|
|
1220
|
+
const P = pascal(name);
|
|
1221
|
+
switch (fw) {
|
|
1222
|
+
case "react":
|
|
1223
|
+
return `import { Suspense } from 'react';
|
|
1224
|
+
import { lazyIsland } from '@atolljs/react-island';
|
|
1225
|
+
|
|
1226
|
+
const ${P}Island = lazyIsland(() => import('./islands/${name}.island'));
|
|
1227
|
+
|
|
1228
|
+
<Suspense fallback={null}>
|
|
1229
|
+
<${P}Island label="${name}" onEvent={(n, p) => console.log(n, p)} />
|
|
1230
|
+
</Suspense>`;
|
|
1231
|
+
case "vue":
|
|
1232
|
+
return `<script setup>
|
|
1233
|
+
import { lazyIsland } from '@atolljs/vue-island';
|
|
1234
|
+
const ${P}Island = lazyIsland(() => import('./islands/${name}.island'));
|
|
1235
|
+
</script>
|
|
1236
|
+
|
|
1237
|
+
<template>
|
|
1238
|
+
<${P}Island label="${name}" />
|
|
1239
|
+
</template>`;
|
|
1240
|
+
case "solid":
|
|
1241
|
+
return `import { lazyIsland } from '@atolljs/solid-island';
|
|
1242
|
+
|
|
1243
|
+
const ${P}Island = lazyIsland(() => import('./islands/${name}.island'));
|
|
1244
|
+
|
|
1245
|
+
<${P}Island label="${name}" />`;
|
|
1246
|
+
case "svelte":
|
|
1247
|
+
return `<script lang="ts">
|
|
1248
|
+
import ${P}Island from './islands/${P}Island.svelte';
|
|
1249
|
+
</script>
|
|
1250
|
+
|
|
1251
|
+
<${P}Island label="${name}" />`;
|
|
1252
|
+
case "angular":
|
|
1253
|
+
return `import { ${P}Island } from './islands/${name}.island';
|
|
1254
|
+
// add ${P}Island to the host component's imports, then:
|
|
1255
|
+
// <${name}-island [props]="{ label: '${name}' }" (incremented)="onIncremented($event)" />`;
|
|
1256
|
+
default:
|
|
1257
|
+
return "";
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
function nestjsModuleFiles(name, dir, opts) {
|
|
1261
|
+
const P = pascal(name);
|
|
1262
|
+
const memImport = opts.mem ? `import { ${opts.mem.export_} } from '${opts.mem.import_}';
|
|
1263
|
+
` : "";
|
|
1264
|
+
const serviceImport = opts.service ? `import { ${opts.service} } from './${name}.service';
|
|
1265
|
+
` : "";
|
|
1266
|
+
const memLine = opts.mem ? ` sharedMemory: ${opts.mem.export_},
|
|
1267
|
+
` : ` // message-only \u2014 add sharedMemory: <contract> to give the pool a buffer
|
|
1268
|
+
`;
|
|
1269
|
+
const providers = opts.service ? ` providers: [${opts.service}],
|
|
1270
|
+
exports: [${opts.service}, AtollModule],
|
|
1271
|
+
` : "";
|
|
1272
|
+
return [
|
|
1273
|
+
{
|
|
1274
|
+
path: `${dir}/${name}.module.ts`,
|
|
1275
|
+
content: `import { Module } from '@nestjs/common';
|
|
1276
|
+
import { Worker } from 'node:worker_threads';
|
|
1277
|
+
import { AtollModule } from '@atolljs/nestjs';
|
|
1278
|
+
${serviceImport}${memImport}
|
|
1279
|
+
/**
|
|
1280
|
+
* ${name} pool boundary \u2014 the feature module owns its worker domain.
|
|
1281
|
+
* Imported by AppModule for the API thread (registerPool spawns workers)
|
|
1282
|
+
* and by the worker entry (runAtollWorker boots the same module in-thread).
|
|
1283
|
+
*/
|
|
1284
|
+
@Module({
|
|
1285
|
+
imports: [
|
|
1286
|
+
AtollModule.registerPool({
|
|
1287
|
+
name: '${name}',
|
|
1288
|
+
// webpack emits the worker entry as its own chunk \u2014 keep the URL inline
|
|
1289
|
+
worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1290
|
+
${memLine} poolSize: 'auto',
|
|
1291
|
+
}),
|
|
1292
|
+
],
|
|
1293
|
+
${providers}})
|
|
1294
|
+
export class ${P}Module {}
|
|
1295
|
+
`
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1299
|
+
content: `// Worker entry \u2014 boots a Nest application context inside the worker and
|
|
1300
|
+
// binds every @AtollService/@AtollTask provider in ${P}Module.
|
|
1301
|
+
import { runAtollWorker } from '@atolljs/nestjs/worker';
|
|
1302
|
+
import { ${P}Module } from './${name}.module';
|
|
1303
|
+
|
|
1304
|
+
void runAtollWorker(${P}Module);
|
|
1305
|
+
`
|
|
1306
|
+
}
|
|
1307
|
+
];
|
|
1308
|
+
}
|
|
1309
|
+
function nestjsServiceFiles(name, dir, mem) {
|
|
1310
|
+
const P = pascal(name);
|
|
1311
|
+
const memImport = mem ? `import { ${mem.export_} } from '${mem.import_}';
|
|
1312
|
+
` : "";
|
|
1313
|
+
const memWrite = mem ? `
|
|
1314
|
+
// shared memory crosses without a message \u2014 pick a real field:
|
|
1315
|
+
// ${mem.export_}.signals.progress.write(100);` : "";
|
|
1316
|
+
return [
|
|
1317
|
+
{
|
|
1318
|
+
path: `${dir}/${name}.service.ts`,
|
|
1319
|
+
content: `import { Injectable } from '@nestjs/common';
|
|
1320
|
+
import { threadId } from 'node:worker_threads';
|
|
1321
|
+
import { AtollService } from '@atolljs/nestjs/decorators';
|
|
1322
|
+
${memImport}
|
|
1323
|
+
/**
|
|
1324
|
+
* Service-level facade \u2014 the class IS the interop surface. Consumers inject
|
|
1325
|
+
* it like any provider; every call dispatches to the '${name}' pool as a
|
|
1326
|
+
* ${P}Service.method task and the body runs on the worker's DI'd instance.
|
|
1327
|
+
*/
|
|
1328
|
+
@Injectable()
|
|
1329
|
+
@AtollService({ pool: '${name}' })
|
|
1330
|
+
export class ${P}Service {
|
|
1331
|
+
/** Runs inside the worker \u2014 the return value crosses postMessage. */
|
|
1332
|
+
async echo(input: string) {${memWrite}
|
|
1333
|
+
return { heard: input, worker: threadId };
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
`
|
|
1337
|
+
},
|
|
1338
|
+
...nestjsModuleFiles(name, dir, { service: `${P}Service`, mem })
|
|
1339
|
+
];
|
|
1340
|
+
}
|
|
1341
|
+
function nestjsMethodFiles(name, dir) {
|
|
1342
|
+
const P = pascal(name);
|
|
1343
|
+
return [
|
|
1344
|
+
{
|
|
1345
|
+
path: `${dir}/${name}.service.ts`,
|
|
1346
|
+
content: `import { Injectable } from '@nestjs/common';
|
|
1347
|
+
import { threadId } from 'node:worker_threads';
|
|
1348
|
+
import { AtollTask } from '@atolljs/nestjs/decorators';
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Method-level offload \u2014 only decorated methods dispatch to the pool; the
|
|
1352
|
+
* rest of the class stays on the API thread. The body executes inside the
|
|
1353
|
+
* worker's own Nest context on the DI-resolved instance.
|
|
1354
|
+
*/
|
|
1355
|
+
@Injectable()
|
|
1356
|
+
export class ${P}Service {
|
|
1357
|
+
@AtollTask({ pool: '${name}' })
|
|
1358
|
+
async run(input: string) {
|
|
1359
|
+
return { heard: input, worker: threadId };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Not decorated \u2014 plain main-thread method. */
|
|
1363
|
+
describe() {
|
|
1364
|
+
return '${name} service, partial offload';
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
`
|
|
1368
|
+
}
|
|
1369
|
+
];
|
|
1370
|
+
}
|
|
1371
|
+
function nestjsHousedFiles(name, dir) {
|
|
1372
|
+
const P = pascal(name);
|
|
1373
|
+
return [
|
|
1374
|
+
{
|
|
1375
|
+
path: `${dir}/${name}-atoll.module.ts`,
|
|
1376
|
+
content: `// Main-side boundary \u2014 registers the '${name}' pool the housed API
|
|
1377
|
+
// runs in. Message-only by default; to share another pool's buffer, wrap
|
|
1378
|
+
// the factory:
|
|
1379
|
+
// worker: withSharedBuffer(
|
|
1380
|
+
// () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1381
|
+
// () => getAtollPool('<other-pool>')?.sharedBuffer,
|
|
1382
|
+
// )
|
|
1383
|
+
import { Module } from '@nestjs/common';
|
|
1384
|
+
import { Worker } from 'node:worker_threads';
|
|
1385
|
+
import { AtollModule } from '@atolljs/nestjs';
|
|
1386
|
+
|
|
1387
|
+
@Module({
|
|
1388
|
+
imports: [
|
|
1389
|
+
AtollModule.registerPool({
|
|
1390
|
+
name: '${name}',
|
|
1391
|
+
worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1392
|
+
poolSize: 2,
|
|
1393
|
+
}),
|
|
1394
|
+
],
|
|
1395
|
+
})
|
|
1396
|
+
export class ${P}AtollModule {}
|
|
1397
|
+
`
|
|
1398
|
+
},
|
|
1399
|
+
{
|
|
1400
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1401
|
+
content: `// Dedicated HTTP worker \u2014 NOT a task worker: it serves requests, so no
|
|
1402
|
+
// runAtollWorker. serveHttp binds an internal 127.0.0.1 port and announces
|
|
1403
|
+
// it via the HTTP_PORT handshake; a respawned worker re-announces.
|
|
1404
|
+
import 'reflect-metadata';
|
|
1405
|
+
import { NestFactory } from '@nestjs/core';
|
|
1406
|
+
import { serveHttp } from '@atolljs/node/http';
|
|
1407
|
+
import { ${P}ApiModule } from './${name}-api.module';
|
|
1408
|
+
|
|
1409
|
+
void (async () => {
|
|
1410
|
+
const app = await NestFactory.create(${P}ApiModule, { logger: ['warn', 'error'] });
|
|
1411
|
+
await app.init();
|
|
1412
|
+
serveHttp(app.getHttpServer(), { listen: 0 });
|
|
1413
|
+
})();
|
|
1414
|
+
`
|
|
1415
|
+
},
|
|
1416
|
+
{
|
|
1417
|
+
path: `${dir}/${name}-api.module.ts`,
|
|
1418
|
+
content: `// Worker-side module \u2014 pure HTTP surface (controllers/providers only).
|
|
1419
|
+
// No registerPool import: pools only exist on the main thread.
|
|
1420
|
+
import { Module } from '@nestjs/common';
|
|
1421
|
+
import { ${P}Controller } from './${name}.controller';
|
|
1422
|
+
|
|
1423
|
+
@Module({ controllers: [${P}Controller] })
|
|
1424
|
+
export class ${P}ApiModule {}
|
|
1425
|
+
`
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
path: `${dir}/${name}.controller.ts`,
|
|
1429
|
+
content: `import { Controller, Get } from '@nestjs/common';
|
|
1430
|
+
import { threadId } from 'node:worker_threads';
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Housed controller \u2014 exists ONLY inside '${name}' pool workers. The main
|
|
1434
|
+
* app never sees these routes: it proxies /api/${name}/* into the workers'
|
|
1435
|
+
* internal listeners, so decorators, DI, and guards execute off-thread.
|
|
1436
|
+
*/
|
|
1437
|
+
@Controller('api/${name}')
|
|
1438
|
+
export class ${P}Controller {
|
|
1439
|
+
/** Which pool worker owns this request. */
|
|
1440
|
+
@Get('whoami')
|
|
1441
|
+
whoami() {
|
|
1442
|
+
return { worker: threadId };
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
`
|
|
1446
|
+
}
|
|
1447
|
+
];
|
|
1448
|
+
}
|
|
1449
|
+
function nestjsHousedWiring(name) {
|
|
1450
|
+
return `main.ts \u2014 proxy the prefix into the pool:
|
|
1451
|
+
|
|
1452
|
+
import { getAtollPool } from '@atolljs/nestjs';
|
|
1453
|
+
import { proxyToWorker, workerHttpPorts } from '@atolljs/node/http';
|
|
1454
|
+
|
|
1455
|
+
const pool = getAtollPool('${name}');
|
|
1456
|
+
if (pool) {
|
|
1457
|
+
const tracker = workerHttpPorts(pool);
|
|
1458
|
+
app.use('/api/${name}', proxyToWorker({
|
|
1459
|
+
pool,
|
|
1460
|
+
tracker,
|
|
1461
|
+
to: '/api/${name}', // express strips the mount \u2014 restore it in-worker
|
|
1462
|
+
}));
|
|
1463
|
+
}`;
|
|
1464
|
+
}
|
|
1465
|
+
function nextjsRouteFiles(name, dir, variant) {
|
|
1466
|
+
const c = camel(name);
|
|
1467
|
+
const P = pascal(name);
|
|
1468
|
+
const workerFactory = `createNodeWorker(
|
|
1469
|
+
new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1470
|
+
)`;
|
|
1471
|
+
if (variant === "task") {
|
|
1472
|
+
return [
|
|
1473
|
+
{
|
|
1474
|
+
path: `${dir}/${name}.contract.ts`,
|
|
1475
|
+
content: `import { z } from 'zod';
|
|
1476
|
+
import { defineSharedMemory, field, type TaskContract } from '@atolljs/core';
|
|
1477
|
+
|
|
1478
|
+
/** Pool-owned shared state \u2014 the route handler reads it with zero dispatch. */
|
|
1479
|
+
export const ${c}Memory = defineSharedMemory({
|
|
1480
|
+
jobsDone: field.number(),
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
const result = z.object({
|
|
1484
|
+
echo: z.string(),
|
|
1485
|
+
ms: z.number(),
|
|
1486
|
+
jobsDone: z.number(),
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
/** Executed inside a worker via TaskRegistry. */
|
|
1490
|
+
export const ${P}Job: TaskContract<[input?: string], z.infer<typeof result>> = {
|
|
1491
|
+
taskId: '${name}.run',
|
|
1492
|
+
resultSchema: result,
|
|
1493
|
+
};
|
|
1494
|
+
`
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1498
|
+
content: `// Worker entry \u2014 the shim MUST be first: it binds self = parentPort
|
|
1499
|
+
// before workerBootstrap wires INIT_MEMORY / EXECUTE_TASK.
|
|
1500
|
+
import '@atolljs/node/shim';
|
|
1501
|
+
import '@atolljs/core/worker/workerBootstrap';
|
|
1502
|
+
import { TaskRegistry } from '@atolljs/core';
|
|
1503
|
+
import { ${c}Memory, ${P}Job } from './${name}.contract';
|
|
1504
|
+
|
|
1505
|
+
TaskRegistry.register(${P}Job, (input = '${name}') => {
|
|
1506
|
+
const t0 = performance.now();
|
|
1507
|
+
const jobs = ${c}Memory.jobsDone.read() + 1;
|
|
1508
|
+
${c}Memory.jobsDone.write(jobs);
|
|
1509
|
+
return { echo: \`worker heard: \${input}\`, ms: performance.now() - t0, jobsDone: jobs };
|
|
1510
|
+
});
|
|
1511
|
+
`
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
path: `${dir}/pool.ts`,
|
|
1515
|
+
content: `import { Worker } from 'node:worker_threads';
|
|
1516
|
+
import { createNodePool, createNodeWorker } from '@atolljs/node';
|
|
1517
|
+
import { ${c}Memory, ${P}Job } from './${name}.contract';
|
|
1518
|
+
|
|
1519
|
+
const create = () => {
|
|
1520
|
+
const pool = createNodePool({
|
|
1521
|
+
sharedMemory: ${c}Memory,
|
|
1522
|
+
poolSize: 'auto',
|
|
1523
|
+
tasks: { run: ${P}Job },
|
|
1524
|
+
createWorker: () =>
|
|
1525
|
+
${workerFactory},
|
|
1526
|
+
});
|
|
1527
|
+
// Read memory via this getter \u2014 Next compiles module graphs per route
|
|
1528
|
+
// layer, so a fresh contract import can be a second, unbound instance.
|
|
1529
|
+
return { pool, memory: ${c}Memory };
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
// Module scope = process scope; globalThis survives dev-mode HMR reloads.
|
|
1533
|
+
export const get${P} = (): ReturnType<typeof create> => {
|
|
1534
|
+
const g = globalThis as { __atoll${P}?: ReturnType<typeof create> };
|
|
1535
|
+
return (g.__atoll${P} ??= create());
|
|
1536
|
+
};
|
|
1537
|
+
`
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
path: `${dir}/route.ts`,
|
|
1541
|
+
content: `import { NextResponse } from 'next/server';
|
|
1542
|
+
import { get${P} } from './pool';
|
|
1543
|
+
|
|
1544
|
+
// worker_threads need the Node.js runtime \u2014 Edge cannot spawn them.
|
|
1545
|
+
export const runtime = 'nodejs';
|
|
1546
|
+
export const dynamic = 'force-dynamic';
|
|
1547
|
+
|
|
1548
|
+
/** POST /api/${name} {"input": "..."} \u2014 dispatch the task into the pool. */
|
|
1549
|
+
export async function POST(req: Request) {
|
|
1550
|
+
const body = await req.json().catch(() => ({}));
|
|
1551
|
+
return NextResponse.json(await get${P}().pool.run(body?.input));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** GET /api/${name} \u2014 read shared state on this thread, no dispatch. */
|
|
1555
|
+
export async function GET() {
|
|
1556
|
+
return NextResponse.json({ jobsDone: get${P}().memory.jobsDone.read() });
|
|
1557
|
+
}
|
|
1558
|
+
`
|
|
1559
|
+
}
|
|
1560
|
+
];
|
|
1561
|
+
}
|
|
1562
|
+
return [
|
|
1563
|
+
{
|
|
1564
|
+
path: `${dir}/${name}.contract.ts`,
|
|
1565
|
+
content: `import { defineSharedMemory, field } from '@atolljs/core';
|
|
1566
|
+
|
|
1567
|
+
/** Pool-owned shared state \u2014 the route handler reads it with zero dispatch. */
|
|
1568
|
+
export const ${c}Memory = defineSharedMemory({
|
|
1569
|
+
jobsDone: field.number(),
|
|
1570
|
+
});
|
|
1571
|
+
`
|
|
1572
|
+
},
|
|
1573
|
+
{
|
|
1574
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1575
|
+
content: `// Worker entry \u2014 shim first, then defineWorker installs the message loop.
|
|
1576
|
+
import '@atolljs/node/shim';
|
|
1577
|
+
import { defineWorker, serviceMethod } from '@atolljs/core';
|
|
1578
|
+
import { z } from 'zod';
|
|
1579
|
+
import { ${c}Memory } from './${name}.contract';
|
|
1580
|
+
|
|
1581
|
+
const echo = serviceMethod({
|
|
1582
|
+
def: { argsSchema: z.tuple([z.string()]), resultSchema: z.string() },
|
|
1583
|
+
run: (message) => {
|
|
1584
|
+
${c}Memory.jobsDone.write(${c}Memory.jobsDone.read() + 1);
|
|
1585
|
+
return \`worker heard: \${message}\`;
|
|
1586
|
+
},
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
export const ${c}Worker = defineWorker({
|
|
1590
|
+
sharedMemory: ${c}Memory,
|
|
1591
|
+
methods: { echo },
|
|
1592
|
+
});
|
|
1593
|
+
export type ${P}Worker = typeof ${c}Worker;
|
|
1594
|
+
`
|
|
1595
|
+
},
|
|
1596
|
+
{
|
|
1597
|
+
path: `${dir}/pool.ts`,
|
|
1598
|
+
content: `import { Worker } from 'node:worker_threads';
|
|
1599
|
+
import { workerClient } from '@atolljs/core';
|
|
1600
|
+
import { createNodePool, createNodeWorker } from '@atolljs/node';
|
|
1601
|
+
import { ${c}Memory } from './${name}.contract';
|
|
1602
|
+
// Type-only \u2014 worker code must not leak into the server bundle.
|
|
1603
|
+
import type { ${P}Worker } from './${name}.worker';
|
|
1604
|
+
|
|
1605
|
+
const create = () => {
|
|
1606
|
+
const pool = createNodePool({
|
|
1607
|
+
sharedMemory: ${c}Memory,
|
|
1608
|
+
poolSize: 'auto',
|
|
1609
|
+
createWorker: () =>
|
|
1610
|
+
${workerFactory},
|
|
1611
|
+
});
|
|
1612
|
+
// Read memory via this getter \u2014 Next compiles module graphs per route
|
|
1613
|
+
// layer, so a fresh contract import can be a second, unbound instance.
|
|
1614
|
+
return { pool, client: workerClient<${P}Worker>(pool), memory: ${c}Memory };
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
// Module scope = process scope; globalThis survives dev-mode HMR reloads.
|
|
1618
|
+
export const get${P} = (): ReturnType<typeof create> => {
|
|
1619
|
+
const g = globalThis as { __atoll${P}?: ReturnType<typeof create> };
|
|
1620
|
+
return (g.__atoll${P} ??= create());
|
|
1621
|
+
};
|
|
1622
|
+
`
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
path: `${dir}/route.ts`,
|
|
1626
|
+
content: `import { NextResponse } from 'next/server';
|
|
1627
|
+
import { get${P} } from './pool';
|
|
1628
|
+
|
|
1629
|
+
// worker_threads need the Node.js runtime \u2014 Edge cannot spawn them.
|
|
1630
|
+
export const runtime = 'nodejs';
|
|
1631
|
+
export const dynamic = 'force-dynamic';
|
|
1632
|
+
|
|
1633
|
+
/** POST /api/${name} {"input": "..."} \u2014 a typed client call into the pool. */
|
|
1634
|
+
export async function POST(req: Request) {
|
|
1635
|
+
const body = await req.json().catch(() => ({}));
|
|
1636
|
+
return NextResponse.json({
|
|
1637
|
+
echo: await get${P}().client.echo(String(body?.input ?? '${name}')),
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/** GET /api/${name} \u2014 read shared state on this thread, no dispatch. */
|
|
1642
|
+
export async function GET() {
|
|
1643
|
+
return NextResponse.json({ jobsDone: get${P}().memory.jobsDone.read() });
|
|
1644
|
+
}
|
|
1645
|
+
`
|
|
1646
|
+
}
|
|
1647
|
+
];
|
|
1648
|
+
}
|
|
1649
|
+
function nextjsComponentFiles(name, dir, mem) {
|
|
1650
|
+
const c = camel(name);
|
|
1651
|
+
const P = pascal(name);
|
|
1652
|
+
return [
|
|
1653
|
+
{
|
|
1654
|
+
path: `${dir}/${name}.tsx`,
|
|
1655
|
+
content: `'use client';
|
|
1656
|
+
import { useTask${mem ? ", useSharedValue" : ""} } from '@atolljs/nextjs';
|
|
1657
|
+
import { ${c} } from './${name}';
|
|
1658
|
+
${mem ? `import { ${mem.export_} } from '${mem.import_}';
|
|
1659
|
+
` : ""}
|
|
1660
|
+
/**
|
|
1661
|
+
* ${P} \u2014 a client component over the ${name} pool. Field reads return
|
|
1662
|
+
* undefined until the contract binds on the client (SSR-safe).
|
|
1663
|
+
*/
|
|
1664
|
+
export function ${P}() {
|
|
1665
|
+
const echo = useTask(${c}.echo);${mem ? `
|
|
1666
|
+
const progress = useSharedValue(${mem.export_}, 'signals.progress');` : ""}
|
|
1667
|
+
return (
|
|
1668
|
+
<button onClick={() => echo.run('ping')}>
|
|
1669
|
+
{echo.data ?? (echo.pending ? 'working\\u2026' : '${name}')}
|
|
1670
|
+
</button>
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
`
|
|
1674
|
+
}
|
|
1675
|
+
];
|
|
1676
|
+
}
|
|
1677
|
+
function nextjsInstrumentationFile(name, dir = "src") {
|
|
1678
|
+
const P = pascal(name);
|
|
1679
|
+
return {
|
|
1680
|
+
path: `${dir}/instrumentation.ts`,
|
|
1681
|
+
content: `/**
|
|
1682
|
+
* Next.js server bootstrap \u2014 runs once when the Node.js runtime boots.
|
|
1683
|
+
* Dynamic imports keep node:worker_threads out of the edge/browser bundles.
|
|
1684
|
+
*/
|
|
1685
|
+
export async function register() {
|
|
1686
|
+
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
|
|
1687
|
+
const { get${P} } = await import('./app/api/${name}/pool');
|
|
1688
|
+
get${P}(); // spawn workers + bind the buffer during startup, not per request
|
|
1689
|
+
}
|
|
1690
|
+
`
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// src/commands.ts
|
|
1695
|
+
var NODE_HOSTS = ["node", "nestjs"];
|
|
1696
|
+
var hostOf = (fw) => NODE_HOSTS.includes(fw) ? "node" : "browser";
|
|
1697
|
+
async function offerInstall(ctx, project, pkgs, installAll = false) {
|
|
1698
|
+
if (hasFlag(ctx.args, "no-install")) {
|
|
1699
|
+
ctx.io.warn("--no-install \u2014 skipping dependency install");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
const missing = missingDeps(project.pkg, pkgs);
|
|
1703
|
+
if (missing.length === 0 && !installAll) return;
|
|
1704
|
+
const cmd = pmInstallCmd(project.pm, installAll ? [] : missing);
|
|
1705
|
+
const yes = await ctx.io.confirm(`install dependencies? (${ctx.io.fmt.accent(cmd)})`, true);
|
|
1706
|
+
if (!yes) {
|
|
1707
|
+
ctx.io.warn(`skipped \u2014 run ${ctx.io.fmt.accent(cmd)} when ready`);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
ctx.io.print(
|
|
1711
|
+
ctx.io.fmt.dim(
|
|
1712
|
+
"note: bootstrap install runs with --min-release-age=0 \u2014 the new .npmrc policy applies from the next install on"
|
|
1713
|
+
)
|
|
1714
|
+
);
|
|
1715
|
+
const [bin, ...rest] = cmd.split(" ").filter(Boolean);
|
|
1716
|
+
const r = spawnSync(bin, [...rest, "--min-release-age=0"], {
|
|
1717
|
+
cwd: project.root ?? ctx.cwd,
|
|
1718
|
+
stdio: "inherit"
|
|
1719
|
+
});
|
|
1720
|
+
if (r.status !== 0) throw new Error(`install failed \u2014 run \`${cmd}\` manually`);
|
|
1721
|
+
}
|
|
1722
|
+
async function runInit(ctx) {
|
|
1723
|
+
const project = detectProject(ctx.cwd);
|
|
1724
|
+
if (!project.root || !project.pkg) {
|
|
1725
|
+
ctx.io.error("no package.json found \u2014 run `atoll new <dir>` to scaffold, or cd into a project");
|
|
1726
|
+
return 1;
|
|
1727
|
+
}
|
|
1728
|
+
const rel = (abs) => relative(ctx.cwd, abs).replaceAll("\\", "/");
|
|
1729
|
+
ctx.io.print(
|
|
1730
|
+
ctx.io.fmt.dim(
|
|
1731
|
+
`detected: ${project.framework} \xB7 ${project.pm} \xB7 root ${project.root}`
|
|
1732
|
+
)
|
|
1733
|
+
);
|
|
1734
|
+
await writeTree(ctx.io, ctx.cwd, [{ path: rel(join3(project.root, ".npmrc")), content: NPMRC }], {
|
|
1735
|
+
force: hasFlag(ctx.args, "force")
|
|
1736
|
+
});
|
|
1737
|
+
await offerInstall(ctx, project, ATOLL_DEPS[project.framework]);
|
|
1738
|
+
const atollDir = join3(project.srcDir, "atoll");
|
|
1739
|
+
const host = hostOf(project.framework);
|
|
1740
|
+
const name = flag(ctx.args, "name") ?? "app";
|
|
1741
|
+
const files = [
|
|
1742
|
+
{ path: `${rel(atollDir)}/${name}.memory.ts`, content: memoryContract(name) },
|
|
1743
|
+
{
|
|
1744
|
+
path: `${rel(atollDir)}/${name}.worker.ts`,
|
|
1745
|
+
content: workerEntry(name, {
|
|
1746
|
+
host,
|
|
1747
|
+
memoryImport: `./${name}.memory`,
|
|
1748
|
+
memoryName: `${camel(name)}Memory`,
|
|
1749
|
+
demoMemoryWrite: true
|
|
1750
|
+
})
|
|
1751
|
+
},
|
|
1752
|
+
{
|
|
1753
|
+
path: `${rel(atollDir)}/${name}.ts`,
|
|
1754
|
+
content: workerClientFile(name, {
|
|
1755
|
+
host,
|
|
1756
|
+
memoryImport: `./${name}.memory`,
|
|
1757
|
+
memoryName: `${camel(name)}Memory`,
|
|
1758
|
+
// src/atoll/x.ts → <root>/dist/x.worker.js
|
|
1759
|
+
distUrl: `${relative(atollDir, join3(project.root, "dist")).replaceAll("\\", "/")}/${name}.worker.js`
|
|
1760
|
+
})
|
|
1761
|
+
}
|
|
1762
|
+
];
|
|
1763
|
+
await writeTree(ctx.io, ctx.cwd, files, { force: hasFlag(ctx.args, "force") });
|
|
1764
|
+
ctx.io.print("");
|
|
1765
|
+
ctx.io.print(ctx.io.fmt.strong("next steps:"));
|
|
1766
|
+
if (host === "node") {
|
|
1767
|
+
ctx.io.print(
|
|
1768
|
+
` bundle the worker entry (see the comment in ${name}.ts), then call ${ctx.io.fmt.accent(`${camel(name)}.echo('hi')`)}`
|
|
1769
|
+
);
|
|
1770
|
+
} else {
|
|
1771
|
+
ctx.io.print(
|
|
1772
|
+
` call ${ctx.io.fmt.accent(`${camel(name)}.echo('hi')`)} \u2014 the pool spawns lazily on first call`
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
ctx.io.print(
|
|
1776
|
+
` ${ctx.io.fmt.accent("atoll add worker|memory|island <name>")} grows it \xB7 ${ctx.io.fmt.accent("atoll doctor")} checks the setup`
|
|
1777
|
+
);
|
|
1778
|
+
return 0;
|
|
1779
|
+
}
|
|
1780
|
+
var ADD_KINDS = [
|
|
1781
|
+
"worker",
|
|
1782
|
+
"memory",
|
|
1783
|
+
"island",
|
|
1784
|
+
"service",
|
|
1785
|
+
"method",
|
|
1786
|
+
"module",
|
|
1787
|
+
"housed",
|
|
1788
|
+
"route",
|
|
1789
|
+
"component",
|
|
1790
|
+
"instrumentation"
|
|
1791
|
+
];
|
|
1792
|
+
var KIND_SCOPE = {
|
|
1793
|
+
worker: null,
|
|
1794
|
+
memory: null,
|
|
1795
|
+
island: ISLAND_FRAMEWORKS,
|
|
1796
|
+
service: ["nestjs"],
|
|
1797
|
+
method: ["nestjs"],
|
|
1798
|
+
module: ["nestjs"],
|
|
1799
|
+
housed: ["nestjs"],
|
|
1800
|
+
route: ["nextjs"],
|
|
1801
|
+
component: ["nextjs"],
|
|
1802
|
+
instrumentation: ["nextjs"]
|
|
1803
|
+
};
|
|
1804
|
+
var KIND_VARIANTS = {
|
|
1805
|
+
island: ["facade", "mono"],
|
|
1806
|
+
route: ["task", "client"]
|
|
1807
|
+
};
|
|
1808
|
+
var FRAMEWORK_WORDS = [
|
|
1809
|
+
"angular",
|
|
1810
|
+
"nestjs",
|
|
1811
|
+
"nextjs",
|
|
1812
|
+
"node",
|
|
1813
|
+
"react",
|
|
1814
|
+
"solid",
|
|
1815
|
+
"svelte",
|
|
1816
|
+
"vanilla",
|
|
1817
|
+
"vue"
|
|
1818
|
+
];
|
|
1819
|
+
async function pickName(ctx, kind, given) {
|
|
1820
|
+
const name = given ?? flag(ctx.args, "name") ?? await ctx.io.ask(`${kind} name (kebab-case):`, "");
|
|
1821
|
+
if (!name || !validName(name)) {
|
|
1822
|
+
ctx.io.error(
|
|
1823
|
+
`invalid ${kind} name ${ctx.io.fmt.accent(name)} \u2014 lowercase kebab-case, e.g. incidents-report`
|
|
1824
|
+
);
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
return name;
|
|
1828
|
+
}
|
|
1829
|
+
function resolveMemory(ctx, dir, name) {
|
|
1830
|
+
if (hasFlag(ctx.args, "no-memory")) return null;
|
|
1831
|
+
const want = flag(ctx.args, "memory") ?? name;
|
|
1832
|
+
const direct = join3(dir, `${want}.memory.ts`);
|
|
1833
|
+
if (existsSync3(direct)) {
|
|
1834
|
+
return { import_: `./${want}.memory`, export_: `${camel(want)}Memory` };
|
|
1835
|
+
}
|
|
1836
|
+
const candidates = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".memory.ts")).map((e) => e.name.slice(0, -".memory.ts".length));
|
|
1837
|
+
const only = candidates.length === 1 ? candidates[0] : null;
|
|
1838
|
+
return only ? { import_: `./${only}.memory`, export_: `${camel(only)}Memory` } : null;
|
|
1839
|
+
}
|
|
1840
|
+
async function runAdd(ctx) {
|
|
1841
|
+
const project = detectProject(ctx.cwd);
|
|
1842
|
+
if (!project.root) {
|
|
1843
|
+
ctx.io.error("no package.json found \u2014 cd into a project or run `atoll new`");
|
|
1844
|
+
return 1;
|
|
1845
|
+
}
|
|
1846
|
+
const rel = (abs) => relative(ctx.cwd, abs).replaceAll("\\", "/");
|
|
1847
|
+
let rest = ctx.args._.slice(1);
|
|
1848
|
+
let framework = flag(ctx.args, "framework");
|
|
1849
|
+
if (rest[0] && FRAMEWORK_WORDS.includes(rest[0])) {
|
|
1850
|
+
framework ??= rest[0];
|
|
1851
|
+
rest = rest.slice(1);
|
|
1852
|
+
}
|
|
1853
|
+
framework ??= project.framework;
|
|
1854
|
+
let kind = rest[0];
|
|
1855
|
+
if (!kind) {
|
|
1856
|
+
const available = ADD_KINDS.filter(
|
|
1857
|
+
(k) => !KIND_SCOPE[k] || KIND_SCOPE[k].includes(framework)
|
|
1858
|
+
);
|
|
1859
|
+
kind = ctx.io.interactive ? await ctx.io.select("add what?", available, available[0]) : void 0;
|
|
1860
|
+
if (!kind) {
|
|
1861
|
+
ctx.io.error(`expected a kind \u2014 ${ADD_KINDS.join(" | ")}`);
|
|
1862
|
+
return 1;
|
|
1863
|
+
}
|
|
1864
|
+
rest = [kind, ...rest.slice(1)];
|
|
1865
|
+
}
|
|
1866
|
+
if (!ADD_KINDS.includes(kind)) {
|
|
1867
|
+
ctx.io.error(`unknown add target ${ctx.io.fmt.accent(kind)} \u2014 expected ${ADD_KINDS.join(" | ")}`);
|
|
1868
|
+
return 1;
|
|
1869
|
+
}
|
|
1870
|
+
const scope = KIND_SCOPE[kind];
|
|
1871
|
+
if (scope && !scope.includes(framework)) {
|
|
1872
|
+
ctx.io.error(
|
|
1873
|
+
`${kind} is for ${scope.join("/")} projects \u2014 detected "${framework}". Pass a framework word (e.g. \`atoll add nestjs ${kind} \u2026\`) to override.`
|
|
1874
|
+
);
|
|
1875
|
+
return 1;
|
|
1876
|
+
}
|
|
1877
|
+
const variants = KIND_VARIANTS[kind];
|
|
1878
|
+
let variant;
|
|
1879
|
+
let nameArg;
|
|
1880
|
+
if (variants) {
|
|
1881
|
+
if (rest[1] && variants.includes(rest[1])) {
|
|
1882
|
+
variant = rest[1];
|
|
1883
|
+
nameArg = rest[2];
|
|
1884
|
+
} else {
|
|
1885
|
+
nameArg = rest[1];
|
|
1886
|
+
}
|
|
1887
|
+
variant = flag(ctx.args, "variant") ?? variant;
|
|
1888
|
+
if (variant && !variants.includes(variant)) {
|
|
1889
|
+
ctx.io.error(`unknown ${kind} variant ${ctx.io.fmt.accent(variant)} \u2014 pick ${variants.join(" | ")}`);
|
|
1890
|
+
return 1;
|
|
1891
|
+
}
|
|
1892
|
+
variant ??= ctx.io.interactive ? await ctx.io.select(`${kind} variant:`, variants, variants[0]) : variants[0];
|
|
1893
|
+
} else {
|
|
1894
|
+
nameArg = rest[1];
|
|
1895
|
+
}
|
|
1896
|
+
const name = await pickName(ctx, kind, nameArg);
|
|
1897
|
+
if (!name) return 1;
|
|
1898
|
+
const force = { force: hasFlag(ctx.args, "force") };
|
|
1899
|
+
const atollDir = flag(ctx.args, "dir") ?? rel(join3(project.srcDir, "atoll"));
|
|
1900
|
+
const c = camel(name);
|
|
1901
|
+
const P = pascal(name);
|
|
1902
|
+
switch (kind) {
|
|
1903
|
+
case "memory": {
|
|
1904
|
+
await writeTree(
|
|
1905
|
+
ctx.io,
|
|
1906
|
+
ctx.cwd,
|
|
1907
|
+
[{ path: `${atollDir}/${name}.memory.ts`, content: memoryContract(name) }],
|
|
1908
|
+
force
|
|
1909
|
+
);
|
|
1910
|
+
ctx.io.print(
|
|
1911
|
+
`
|
|
1912
|
+
bind it: pass ${ctx.io.fmt.accent(`${c}Memory`)} as ${ctx.io.fmt.accent("sharedMemory")} to defineWorker/connectWorker,`
|
|
1913
|
+
);
|
|
1914
|
+
ctx.io.print(
|
|
1915
|
+
`or ${ctx.io.fmt.accent(`atoll add worker ${name}`)} in the same directory wires it automatically.`
|
|
1916
|
+
);
|
|
1917
|
+
return 0;
|
|
1918
|
+
}
|
|
1919
|
+
case "worker": {
|
|
1920
|
+
const dir = atollDir;
|
|
1921
|
+
mkdirSync2(join3(ctx.cwd, dir), { recursive: true });
|
|
1922
|
+
const host = hostOf(framework);
|
|
1923
|
+
const mem = resolveMemory(ctx, join3(ctx.cwd, dir), name);
|
|
1924
|
+
const opts = {
|
|
1925
|
+
host,
|
|
1926
|
+
memoryImport: mem?.import_ ?? null,
|
|
1927
|
+
memoryName: mem?.export_,
|
|
1928
|
+
distUrl: `${relative(join3(ctx.cwd, dir), join3(project.root, "dist")).replaceAll("\\", "/")}/${name}.worker.js`
|
|
1929
|
+
};
|
|
1930
|
+
const bindings = workerBindingsFile(name, framework, dir, mem);
|
|
1931
|
+
await writeTree(
|
|
1932
|
+
ctx.io,
|
|
1933
|
+
ctx.cwd,
|
|
1934
|
+
[
|
|
1935
|
+
{ path: `${dir}/${name}.worker.ts`, content: workerEntry(name, opts) },
|
|
1936
|
+
{ path: `${dir}/${name}.ts`, content: workerClientFile(name, opts) },
|
|
1937
|
+
...bindings ? [bindings] : []
|
|
1938
|
+
],
|
|
1939
|
+
force
|
|
1940
|
+
);
|
|
1941
|
+
ctx.io.print("");
|
|
1942
|
+
if (host === "node") {
|
|
1943
|
+
ctx.io.print(`bundle before spawning \u2014 add to package.json scripts:`);
|
|
1944
|
+
ctx.io.print(
|
|
1945
|
+
ctx.io.fmt.accent(
|
|
1946
|
+
` "bundle:${name}": "esbuild ${dir}/${name}.worker.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/${name}.worker.js"`
|
|
1947
|
+
)
|
|
1948
|
+
);
|
|
1949
|
+
} else {
|
|
1950
|
+
ctx.io.print(
|
|
1951
|
+
`call ${ctx.io.fmt.accent(`${c}.echo('hi')`)} \u2014 the pool spawns lazily on first call`
|
|
1952
|
+
);
|
|
1953
|
+
if (bindings) ctx.io.print(`framework bindings: ${ctx.io.fmt.accent(bindings.path)}`);
|
|
1954
|
+
}
|
|
1955
|
+
return 0;
|
|
1956
|
+
}
|
|
1957
|
+
case "island": {
|
|
1958
|
+
const dir = flag(ctx.args, "dir") ?? rel(join3(project.srcDir, "islands"));
|
|
1959
|
+
const files = variant === "facade" ? islandFacadeFiles(name, framework, dir) : islandFiles(name, framework, dir);
|
|
1960
|
+
await writeTree(ctx.io, ctx.cwd, files, force);
|
|
1961
|
+
const usage = variant === "facade" ? islandFacadeUsage(name, framework) : islandUsage(name, framework);
|
|
1962
|
+
if (usage) {
|
|
1963
|
+
ctx.io.print(ctx.io.fmt.strong("\nshell side:"));
|
|
1964
|
+
ctx.io.print(usage);
|
|
1965
|
+
}
|
|
1966
|
+
return 0;
|
|
1967
|
+
}
|
|
1968
|
+
case "service": {
|
|
1969
|
+
const dir = atollDir;
|
|
1970
|
+
mkdirSync2(join3(ctx.cwd, dir), { recursive: true });
|
|
1971
|
+
const mem = resolveMemory(ctx, join3(ctx.cwd, dir), name);
|
|
1972
|
+
await writeTree(ctx.io, ctx.cwd, nestjsServiceFiles(name, dir, mem), force);
|
|
1973
|
+
ctx.io.print("");
|
|
1974
|
+
ctx.io.print(
|
|
1975
|
+
`inject ${ctx.io.fmt.accent(`${P}Service`)} anywhere \u2014 every method dispatches to the ${ctx.io.fmt.accent(`'${name}'`)} pool.`
|
|
1976
|
+
);
|
|
1977
|
+
return 0;
|
|
1978
|
+
}
|
|
1979
|
+
case "method": {
|
|
1980
|
+
const dir = atollDir;
|
|
1981
|
+
mkdirSync2(join3(ctx.cwd, dir), { recursive: true });
|
|
1982
|
+
await writeTree(ctx.io, ctx.cwd, nestjsMethodFiles(name, dir), force);
|
|
1983
|
+
ctx.io.print("");
|
|
1984
|
+
ctx.io.print(
|
|
1985
|
+
`needs a '${name}' pool \u2014 ${ctx.io.fmt.accent(`atoll add nestjs module ${name}`)} registers one,`
|
|
1986
|
+
);
|
|
1987
|
+
ctx.io.print(`and the worker entry must runAtollWorker a module providing ${P}Service.`);
|
|
1988
|
+
return 0;
|
|
1989
|
+
}
|
|
1990
|
+
case "module": {
|
|
1991
|
+
const dir = atollDir;
|
|
1992
|
+
mkdirSync2(join3(ctx.cwd, dir), { recursive: true });
|
|
1993
|
+
const mem = resolveMemory(ctx, join3(ctx.cwd, dir), name);
|
|
1994
|
+
await writeTree(ctx.io, ctx.cwd, nestjsModuleFiles(name, dir, { mem }), force);
|
|
1995
|
+
ctx.io.print("");
|
|
1996
|
+
ctx.io.print(
|
|
1997
|
+
`import ${ctx.io.fmt.accent(`${P}Module`)} into AppModule \u2014 then ${ctx.io.fmt.accent(`atoll add nestjs service ${name}`)} adds the facade.`
|
|
1998
|
+
);
|
|
1999
|
+
return 0;
|
|
2000
|
+
}
|
|
2001
|
+
case "housed": {
|
|
2002
|
+
const dir = atollDir;
|
|
2003
|
+
mkdirSync2(join3(ctx.cwd, dir), { recursive: true });
|
|
2004
|
+
await writeTree(ctx.io, ctx.cwd, nestjsHousedFiles(name, dir), force);
|
|
2005
|
+
ctx.io.print("");
|
|
2006
|
+
ctx.io.print(ctx.io.fmt.strong("wire the gateway:"));
|
|
2007
|
+
ctx.io.print(nestjsHousedWiring(name));
|
|
2008
|
+
ctx.io.print(ctx.io.fmt.dim(`routes live only in workers \u2014 GET /api/${name}/whoami`));
|
|
2009
|
+
return 0;
|
|
2010
|
+
}
|
|
2011
|
+
case "route": {
|
|
2012
|
+
const dir = flag(ctx.args, "dir") ?? rel(join3(project.srcDir, "app/api", name));
|
|
2013
|
+
await writeTree(
|
|
2014
|
+
ctx.io,
|
|
2015
|
+
ctx.cwd,
|
|
2016
|
+
nextjsRouteFiles(name, dir, variant),
|
|
2017
|
+
force
|
|
2018
|
+
);
|
|
2019
|
+
ctx.io.print("");
|
|
2020
|
+
ctx.io.print(
|
|
2021
|
+
`${ctx.io.fmt.accent(`POST /api/${name}`)} dispatches into the pool \xB7 ${ctx.io.fmt.accent(`GET /api/${name}`)} reads shared memory.`
|
|
2022
|
+
);
|
|
2023
|
+
ctx.io.print(
|
|
2024
|
+
ctx.io.fmt.dim(`warm it at boot: atoll add nextjs instrumentation ${name}`)
|
|
2025
|
+
);
|
|
2026
|
+
return 0;
|
|
2027
|
+
}
|
|
2028
|
+
case "component": {
|
|
2029
|
+
const dir = atollDir;
|
|
2030
|
+
mkdirSync2(join3(ctx.cwd, dir), { recursive: true });
|
|
2031
|
+
const mem = resolveMemory(ctx, join3(ctx.cwd, dir), name);
|
|
2032
|
+
await writeTree(ctx.io, ctx.cwd, nextjsComponentFiles(name, dir, mem), force);
|
|
2033
|
+
ctx.io.print("");
|
|
2034
|
+
ctx.io.print(
|
|
2035
|
+
`expects the ${ctx.io.fmt.accent(`./${name}`)} client + contract beside it \u2014 ${ctx.io.fmt.accent(`atoll add nextjs worker ${name}`)} first.`
|
|
2036
|
+
);
|
|
2037
|
+
return 0;
|
|
2038
|
+
}
|
|
2039
|
+
case "instrumentation": {
|
|
2040
|
+
await writeTree(
|
|
2041
|
+
ctx.io,
|
|
2042
|
+
ctx.cwd,
|
|
2043
|
+
[nextjsInstrumentationFile(name, rel(project.srcDir))],
|
|
2044
|
+
force
|
|
2045
|
+
);
|
|
2046
|
+
ctx.io.print(
|
|
2047
|
+
`
|
|
2048
|
+
pools warm at boot \u2014 extend register() as more routes appear.`
|
|
2049
|
+
);
|
|
2050
|
+
return 0;
|
|
2051
|
+
}
|
|
2052
|
+
default:
|
|
2053
|
+
ctx.io.error(`unhandled add kind ${kind}`);
|
|
2054
|
+
return 1;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
var NEW_FRAMEWORKS = ["react", "vue", "solid", "svelte", "node"];
|
|
2058
|
+
async function runCreate(ctx) {
|
|
2059
|
+
let dirName = ctx.args._[1] ?? flag(ctx.args, "dir");
|
|
2060
|
+
if (!dirName) dirName = await ctx.io.ask("project directory:", "my-atoll-app");
|
|
2061
|
+
if (!validName(dirName)) {
|
|
2062
|
+
ctx.io.error(`invalid directory name ${ctx.io.fmt.accent(dirName)}`);
|
|
2063
|
+
return 1;
|
|
2064
|
+
}
|
|
2065
|
+
const fwFlag = flag(ctx.args, "framework");
|
|
2066
|
+
const fw = fwFlag ?? await ctx.io.select("framework:", NEW_FRAMEWORKS, "react");
|
|
2067
|
+
if (fw === "angular") {
|
|
2068
|
+
ctx.io.error("angular apps scaffold through the CLI: `ng new <name>`, then `atoll init` + `atoll add island`");
|
|
2069
|
+
return 1;
|
|
2070
|
+
}
|
|
2071
|
+
if (!NEW_FRAMEWORKS.includes(fw)) {
|
|
2072
|
+
ctx.io.error(`unknown framework "${fw}" \u2014 pick from ${NEW_FRAMEWORKS.join(", ")}`);
|
|
2073
|
+
return 1;
|
|
2074
|
+
}
|
|
2075
|
+
const target = join3(ctx.cwd, dirName);
|
|
2076
|
+
if (existsSync3(target) && readdirSync(target).length > 0) {
|
|
2077
|
+
ctx.io.error(`${dirName}/ exists and is not empty`);
|
|
2078
|
+
return 1;
|
|
2079
|
+
}
|
|
2080
|
+
mkdirSync2(target, { recursive: true });
|
|
2081
|
+
const files = fw === "node" ? nodeScaffoldFiles(dirName) : scaffoldFiles(dirName, fw);
|
|
2082
|
+
await writeTree(ctx.io, target, files, { force: true });
|
|
2083
|
+
const written = JSON.parse(readFileSync2(join3(target, "package.json"), "utf8"));
|
|
2084
|
+
const allDeps = [
|
|
2085
|
+
...Object.keys(written.dependencies ?? {}),
|
|
2086
|
+
...Object.keys(written.devDependencies ?? {})
|
|
2087
|
+
];
|
|
2088
|
+
const pm = flag(ctx.args, "pm") ?? "npm";
|
|
2089
|
+
await offerInstall(
|
|
2090
|
+
{ ...ctx, cwd: target },
|
|
2091
|
+
{ root: target, pkg: {}, framework: "vanilla", pm, srcDir: target },
|
|
2092
|
+
allDeps,
|
|
2093
|
+
true
|
|
2094
|
+
// fresh scaffold — the deps are declared, nothing is installed yet
|
|
2095
|
+
);
|
|
2096
|
+
ctx.io.print("");
|
|
2097
|
+
ctx.io.print(`done \u2014 ${ctx.io.fmt.accent(`cd ${dirName} && ${pm} run dev`)}`);
|
|
2098
|
+
return 0;
|
|
2099
|
+
}
|
|
2100
|
+
function runDoctor(ctx) {
|
|
2101
|
+
const project = detectProject(ctx.cwd);
|
|
2102
|
+
const fix = hasFlag(ctx.args, "fix");
|
|
2103
|
+
const checks = [
|
|
2104
|
+
{
|
|
2105
|
+
label: "node >= 22.18 (type-stripped CLI) / >= 20 (runtime)",
|
|
2106
|
+
run: () => {
|
|
2107
|
+
const [maj, min] = process.versions.node.split(".").map(Number);
|
|
2108
|
+
return maj > 22 || maj === 22 && min >= 18 ? { level: "pass", detail: `v${process.versions.node}` } : maj >= 20 ? { level: "warn", detail: `v${process.versions.node} \u2014 runtime OK; the CLI bin needs >=22.18` } : { level: "fail", detail: `v${process.versions.node}` };
|
|
2109
|
+
}
|
|
2110
|
+
},
|
|
2111
|
+
{
|
|
2112
|
+
label: "package.json",
|
|
2113
|
+
run: () => project.pkg ? { level: "pass", detail: `${project.root}/package.json` } : { level: "fail", detail: "not found \u2014 run `atoll new` or cd into a project" }
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
label: "@atolljs/* dependencies",
|
|
2117
|
+
run: () => {
|
|
2118
|
+
const deps = { ...project.pkg?.dependencies, ...project.pkg?.devDependencies };
|
|
2119
|
+
const found = Object.keys(deps).filter((d) => d.startsWith("@atolljs/"));
|
|
2120
|
+
return found.length ? { level: "pass", detail: found.join(", ") } : { level: "warn", detail: "none \u2014 `atoll init` installs what the framework needs" };
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
2123
|
+
{
|
|
2124
|
+
label: ".npmrc supply-chain policy (min-release-age)",
|
|
2125
|
+
run: () => {
|
|
2126
|
+
const p = join3(project.root ?? ctx.cwd, ".npmrc");
|
|
2127
|
+
const has = existsSync3(p) && readFileSync2(p, "utf8").includes("min-release-age");
|
|
2128
|
+
if (has) return { level: "pass" };
|
|
2129
|
+
if (fix) {
|
|
2130
|
+
writeFileSync2(p, NPMRC);
|
|
2131
|
+
return { level: "pass", detail: "written (--fix)" };
|
|
2132
|
+
}
|
|
2133
|
+
return { level: "warn", detail: "missing \u2014 `atoll doctor --fix` writes the hardened .npmrc" };
|
|
2134
|
+
}
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
label: "lockfile present",
|
|
2138
|
+
run: () => existsSync3(join3(project.root ?? ctx.cwd, "package-lock.json")) || existsSync3(join3(project.root ?? ctx.cwd, "pnpm-lock.yaml")) || existsSync3(join3(project.root ?? ctx.cwd, "yarn.lock")) ? { level: "pass" } : { level: "warn", detail: "no lockfile \u2014 installs are not reproducible" }
|
|
2139
|
+
},
|
|
2140
|
+
{
|
|
2141
|
+
label: "worker entries bundler-detectable",
|
|
2142
|
+
run: () => {
|
|
2143
|
+
if (!project.srcDir || !existsSync3(project.srcDir)) return { level: "pass" };
|
|
2144
|
+
let scanned = 0;
|
|
2145
|
+
let bad = 0;
|
|
2146
|
+
const walk = (d) => {
|
|
2147
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
2148
|
+
const p = join3(d, e.name);
|
|
2149
|
+
if (e.isDirectory() && e.name !== "node_modules") walk(p);
|
|
2150
|
+
else if (/\.(ts|tsx|js|mjs)$/.test(e.name)) {
|
|
2151
|
+
scanned++;
|
|
2152
|
+
const src = readFileSync2(p, "utf8");
|
|
2153
|
+
const entries = src.match(/new Worker\s*\(/g) ?? [];
|
|
2154
|
+
const detectable = src.match(/new Worker\s*\(\s*new URL\s*\(/g) ?? [];
|
|
2155
|
+
bad += entries.length - detectable.length;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
walk(project.srcDir);
|
|
2160
|
+
return bad ? { level: "fail", detail: `${bad} new Worker() call(s) without \`new URL(..., import.meta.url)\` \u2014 bundlers can't see them` } : { level: "pass", detail: `${scanned} files scanned` };
|
|
2161
|
+
}
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
label: "COOP/COEP headers (SharedArrayBuffer gate)",
|
|
2165
|
+
run: () => {
|
|
2166
|
+
if (NODE_HOSTS.includes(project.framework)) {
|
|
2167
|
+
return { level: "pass", detail: "n/a \u2014 node runtime" };
|
|
2168
|
+
}
|
|
2169
|
+
const vite = ["vite.config.ts", "vite.config.js", "vite.config.mts"].map((f) => join3(project.root ?? ctx.cwd, f)).find(existsSync3);
|
|
2170
|
+
if (!vite) return { level: "warn", detail: "no vite config found \u2014 verify headers on your server" };
|
|
2171
|
+
const src = readFileSync2(vite, "utf8");
|
|
2172
|
+
return src.includes("Cross-Origin-Opener-Policy") && src.includes("Cross-Origin-Embedder-Policy") ? { level: "pass" } : { level: "warn", detail: `${vite.split(/[\\/]/).pop()} lacks COOP/COEP \u2014 shared memory will be gated in dev` };
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
];
|
|
2176
|
+
let fails = 0;
|
|
2177
|
+
for (const c of checks) {
|
|
2178
|
+
const r = c.run();
|
|
2179
|
+
const mark = r.level === "pass" ? "ok" : r.level === "warn" ? "warn" : "error";
|
|
2180
|
+
ctx.io[mark](`${c.label}${r.detail ? ctx.io.fmt.dim(` \u2014 ${r.detail}`) : ""}`);
|
|
2181
|
+
if (r.level === "fail") fails++;
|
|
2182
|
+
}
|
|
2183
|
+
return fails ? 1 : 0;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// src/cli.ts
|
|
2187
|
+
var HELP = (fmt) => `${fmt.strong("atoll")} \u2014 bootstrap multithreading ${fmt.dim("(experimental)")}
|
|
2188
|
+
|
|
2189
|
+
${fmt.accent("atoll new <dir>")} [--framework react|vue|solid|svelte|node] [--pm npm]
|
|
2190
|
+
Scaffold a fresh app \u2014 vite shell + a worker-rendered island,
|
|
2191
|
+
or a tsx Node service with a pooled worker.
|
|
2192
|
+
|
|
2193
|
+
${fmt.accent("atoll init")} [--name app] [--force]
|
|
2194
|
+
Wire Atoll into the current project: hardened .npmrc, package
|
|
2195
|
+
install, and a contract + worker + client spine under src/atoll/.
|
|
2196
|
+
|
|
2197
|
+
${fmt.accent("atoll add [framework] <kind> [variant] [name]")}
|
|
2198
|
+
worker pool + typed client (+ framework bindings on UI projects)
|
|
2199
|
+
memory shared-memory contract
|
|
2200
|
+
island worker-rendered UI \u2014 facade (contract + lazy import) | mono
|
|
2201
|
+
service nestjs: @AtollService class facade + pool module + worker
|
|
2202
|
+
method nestjs: @AtollTask method-level offload
|
|
2203
|
+
module nestjs: registerPool boundary module + worker entry
|
|
2204
|
+
housed nestjs: route subtree served entirely inside workers
|
|
2205
|
+
route nextjs: api route pool \u2014 task | client variants
|
|
2206
|
+
component nextjs: 'use client' hooks component over a pool
|
|
2207
|
+
instrumentation nextjs: server bootstrap that warms route pools
|
|
2208
|
+
|
|
2209
|
+
Frameworks: react vue solid svelte angular nestjs nextjs node
|
|
2210
|
+
[--dir <path>] [--memory <name>|--no-memory] [--variant <v>] [--force]
|
|
2211
|
+
|
|
2212
|
+
${fmt.accent("atoll doctor")} [--fix]
|
|
2213
|
+
Check the setup \u2014 deps, lockfile, .npmrc policy, bundler-detectable
|
|
2214
|
+
worker entries, COOP/COEP headers. --fix writes a hardened .npmrc.
|
|
2215
|
+
|
|
2216
|
+
${fmt.dim(`flags: --yes/-y accepts defaults, --force overwrites existing files,
|
|
2217
|
+
--no-install skips dependency installation`)}
|
|
2218
|
+
`;
|
|
2219
|
+
var DISCLAIMER = "atoll is experimental \u2014 report bugs or feedback: https://github.com/jwhenry3/atolljs/issues";
|
|
2220
|
+
async function runCli(argv, io, cwd) {
|
|
2221
|
+
const args = parseArgs(argv);
|
|
2222
|
+
const ctx = { io, cwd, args };
|
|
2223
|
+
const [cmd] = args._;
|
|
2224
|
+
io.warn(io.fmt.dim(DISCLAIMER));
|
|
2225
|
+
try {
|
|
2226
|
+
switch (cmd) {
|
|
2227
|
+
case "new":
|
|
2228
|
+
case "create":
|
|
2229
|
+
return await runCreate(ctx);
|
|
2230
|
+
case "init":
|
|
2231
|
+
return await runInit(ctx);
|
|
2232
|
+
case "add":
|
|
2233
|
+
return await runAdd(ctx);
|
|
2234
|
+
case "doctor":
|
|
2235
|
+
return runDoctor(ctx);
|
|
2236
|
+
case "help":
|
|
2237
|
+
case void 0:
|
|
2238
|
+
io.print(HELP(io.fmt));
|
|
2239
|
+
return cmd === void 0 ? 0 : 0;
|
|
2240
|
+
default:
|
|
2241
|
+
io.error(`unknown command ${io.fmt.accent(cmd)}`);
|
|
2242
|
+
io.print(HELP(io.fmt));
|
|
2243
|
+
return 1;
|
|
2244
|
+
}
|
|
2245
|
+
} catch (e) {
|
|
2246
|
+
io.error(e instanceof Error ? e.message : String(e));
|
|
2247
|
+
return 1;
|
|
2248
|
+
} finally {
|
|
2249
|
+
io.close();
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
var invokedAs = process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll("\\", "/"));
|
|
2253
|
+
if (invokedAs) {
|
|
2254
|
+
const args = parseArgs(process.argv.slice(2));
|
|
2255
|
+
const interactive = process.stdin.isTTY === true && !hasFlag(args, "yes", "y");
|
|
2256
|
+
const code = await runCli(process.argv.slice(2), consoleIo(interactive), process.cwd());
|
|
2257
|
+
process.exit(code);
|
|
2258
|
+
}
|
|
2259
|
+
export {
|
|
2260
|
+
runCli
|
|
2261
|
+
};
|