@atolljs/cli 0.0.0-stage → 0.1.5
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 +42 -2
- package/dist/cli.js +2270 -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 +1643 -0
package/src/templates.ts
ADDED
|
@@ -0,0 +1,1643 @@
|
|
|
1
|
+
import { camel, pascal } from './parse.ts';
|
|
2
|
+
import type { Framework } from './project.ts';
|
|
3
|
+
import type { OutFile } from './files.ts';
|
|
4
|
+
|
|
5
|
+
/** The hardened .npmrc the CLI drops into every project it touches. */
|
|
6
|
+
export const NPMRC = `# Supply-chain hardening — read by every npm command run in this directory.
|
|
7
|
+
#
|
|
8
|
+
# Newly published versions must age 7 days before they can enter the
|
|
9
|
+
# lockfile (requires npm >= 11.10 — shipped with Node 26; older npm
|
|
10
|
+
# silently ignores this key).
|
|
11
|
+
min-release-age=7
|
|
12
|
+
# Install lifecycle scripts are the dominant malware vector — none run
|
|
13
|
+
# during install/ci. If a native dep ever legitimately needs one:
|
|
14
|
+
# npm rebuild <pkg> --ignore-scripts=false
|
|
15
|
+
ignore-scripts=true
|
|
16
|
+
# \`npm install <pkg>\` writes exact pins, not ^ ranges.
|
|
17
|
+
save-exact=true
|
|
18
|
+
fund=false
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export const GITIGNORE = `node_modules/
|
|
22
|
+
dist/
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
/* ── shared-memory contract ─────────────────────────────────────────────── */
|
|
26
|
+
|
|
27
|
+
export function memoryContract(name: string): string {
|
|
28
|
+
const c = camel(name);
|
|
29
|
+
return `import { defineSharedMemory, field, mz } from '@atolljs/core';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ${c} shared memory — the single source of truth for state both threads
|
|
33
|
+
* touch. The spec compiles to one deterministic fixed-width layout; main
|
|
34
|
+
* thread and workers bind the same declaration, so the two sides can never
|
|
35
|
+
* disagree on byte offsets.
|
|
36
|
+
*/
|
|
37
|
+
export const ${c}Memory = defineSharedMemory({
|
|
38
|
+
// Versioned scalars — observe()/watch() park on Atomics; zero postMessage.
|
|
39
|
+
signals: {
|
|
40
|
+
progress: field.number(),
|
|
41
|
+
},
|
|
42
|
+
// Structured snapshots — one inline record, rewritten wholesale.
|
|
43
|
+
state: {
|
|
44
|
+
summary: field.object({
|
|
45
|
+
schema: mz.object({
|
|
46
|
+
total: mz.f64(),
|
|
47
|
+
errors: mz.f64(),
|
|
48
|
+
updatedAt: mz.u32(),
|
|
49
|
+
}),
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
export type ${pascal(name)}Memory = typeof ${c}Memory;
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ── worker pool (defineWorker + typed client) ──────────────────────────── */
|
|
58
|
+
|
|
59
|
+
export interface WorkerOpts {
|
|
60
|
+
/** 'browser' uses connectWorker + DOM Worker; 'node' uses createNodePool. */
|
|
61
|
+
host: 'browser' | 'node';
|
|
62
|
+
/** Import specifier for a shared-memory contract, or null. */
|
|
63
|
+
memoryImport?: string | null;
|
|
64
|
+
/** Memory export name (camelCase, e.g. \`appMemory\`). */
|
|
65
|
+
memoryName?: string;
|
|
66
|
+
/** URL for the bundled node worker, relative to the client file. */
|
|
67
|
+
distUrl?: string;
|
|
68
|
+
/** Emit writes to the generated contract's fields inside the echo task —
|
|
69
|
+
* only valid when memoryName refers to a CLI-generated contract. */
|
|
70
|
+
demoMemoryWrite?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function workerEntry(name: string, opts: WorkerOpts): string {
|
|
74
|
+
const c = camel(name);
|
|
75
|
+
const memImport = opts.memoryImport
|
|
76
|
+
? `import { ${opts.memoryName} } from '${opts.memoryImport}';\n`
|
|
77
|
+
: '';
|
|
78
|
+
const memLine = opts.memoryName ? ` sharedMemory: ${opts.memoryName},\n` : '';
|
|
79
|
+
const shim = opts.host === 'node'
|
|
80
|
+
? `// Node worker entry — the shim binds self = parentPort BEFORE\n// defineWorker's bootstrap evaluates; import order is the contract.\nimport '@atolljs/node/shim';\n`
|
|
81
|
+
: '';
|
|
82
|
+
return `${shim}import { defineWorker, serviceMethod } from '@atolljs/core';
|
|
83
|
+
import { z } from 'zod';
|
|
84
|
+
${memImport}
|
|
85
|
+
const echo = serviceMethod({
|
|
86
|
+
def: { argsSchema: z.tuple([z.string()]), resultSchema: z.string() },
|
|
87
|
+
run: (message) => {${
|
|
88
|
+
opts.demoMemoryWrite && opts.memoryName
|
|
89
|
+
? `
|
|
90
|
+
// Shared memory crosses the boundary without a message — the main
|
|
91
|
+
// thread can read these the moment the task resolves.
|
|
92
|
+
${opts.memoryName}.signals.progress.write(100);
|
|
93
|
+
${opts.memoryName}.state.summary.write({
|
|
94
|
+
total: 1,
|
|
95
|
+
errors: 0,
|
|
96
|
+
updatedAt: Math.floor(Date.now() / 1000),
|
|
97
|
+
});`
|
|
98
|
+
: ''
|
|
99
|
+
}
|
|
100
|
+
return \`worker heard: \${message}\`;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* ${c} worker entry — defineWorker installs the message loop and registers
|
|
106
|
+
* every method. The main thread imports only the TYPE (typeof ${c}Worker)
|
|
107
|
+
* and drives calls through the pool client.
|
|
108
|
+
*/
|
|
109
|
+
export const ${c}Worker = defineWorker({
|
|
110
|
+
${memLine} methods: { echo },
|
|
111
|
+
});
|
|
112
|
+
export type ${pascal(name)}Worker = typeof ${c}Worker;
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function workerClientFile(name: string, opts: WorkerOpts): string {
|
|
117
|
+
const c = camel(name);
|
|
118
|
+
const P = pascal(name);
|
|
119
|
+
const memImport = opts.memoryImport
|
|
120
|
+
? `import { ${opts.memoryName} } from '${opts.memoryImport}';\n`
|
|
121
|
+
: '';
|
|
122
|
+
const memLine = opts.memoryName ? ` sharedMemory: ${opts.memoryName},\n` : '';
|
|
123
|
+
if (opts.host === 'node') {
|
|
124
|
+
return `import { Worker } from 'node:worker_threads';
|
|
125
|
+
import { workerClient, defineTask } from '@atolljs/core';
|
|
126
|
+
import { createNodePool } from '@atolljs/node';
|
|
127
|
+
${memImport}import type { ${P}Worker } from './${name}.worker';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ${c} pool — node:worker_threads workers adapted to the pool surface.
|
|
131
|
+
* The worker entry must be bundled before spawning (Node workers run
|
|
132
|
+
* outside tsx's loader):
|
|
133
|
+
*
|
|
134
|
+
* esbuild src/atoll/${name}.worker.ts --bundle --platform=node \\
|
|
135
|
+
* --format=esm --packages=external --outfile=dist/${name}.worker.js
|
|
136
|
+
*/
|
|
137
|
+
export const ${c}Pool = createNodePool({
|
|
138
|
+
worker: () => new Worker(new URL('${opts.distUrl ?? `./dist/${name}.worker.js`}', import.meta.url)),
|
|
139
|
+
${memLine} poolSize: 'auto',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/** Typed client — calls read like the worker's own method names. */
|
|
143
|
+
export const ${c} = workerClient<${P}Worker>(${c}Pool);
|
|
144
|
+
|
|
145
|
+
/** Latest-wins task over a call — { data, pending, settled, elapsedMs }. */
|
|
146
|
+
export const ${c}EchoTask = defineTask((message: string) => ${c}.echo(message));
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
return `import { connectWorker, defineTask } from '@atolljs/core';
|
|
150
|
+
${memImport}import type { ${P}Worker } from './${name}.worker';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* ${c} — typed client over the worker pool. Spawning is lazy, so importing
|
|
154
|
+
* this module never touches Worker (SSR-safe); the first method call (or
|
|
155
|
+
* ${c}.start()) brings the pool up. Keep the new Worker(new URL(...)) call
|
|
156
|
+
* inline — bundlers detect worker entries by that exact shape.
|
|
157
|
+
*/
|
|
158
|
+
export const ${c} = connectWorker<${P}Worker>({
|
|
159
|
+
worker: () =>
|
|
160
|
+
new Worker(new URL('./${name}.worker.ts', import.meta.url), { type: 'module' }),
|
|
161
|
+
${memLine} poolSize: 'auto',
|
|
162
|
+
});
|
|
163
|
+
export type ${P}Client = typeof ${c};
|
|
164
|
+
|
|
165
|
+
/** Latest-wins task over a call — { data, pending, settled, elapsedMs }. */
|
|
166
|
+
export const ${c}EchoTask = defineTask((message: string) => ${c}.echo(message));
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ── islands — worker entry per framework ───────────────────────────────── */
|
|
171
|
+
|
|
172
|
+
const ISLAND_EXT: Record<string, string> = {
|
|
173
|
+
react: 'tsx',
|
|
174
|
+
vue: 'ts',
|
|
175
|
+
solid: 'tsx',
|
|
176
|
+
svelte: 'ts',
|
|
177
|
+
angular: 'ts',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Worker-entry files for `add island`. Emits the component + worker. */
|
|
181
|
+
export function islandFiles(name: string, fw: Framework, dir = 'src/islands'): OutFile[] {
|
|
182
|
+
const P = pascal(name);
|
|
183
|
+
switch (fw) {
|
|
184
|
+
case 'react':
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
path: `${dir}/${name}.worker.tsx`,
|
|
188
|
+
content: `import { useState } from 'react';
|
|
189
|
+
import { defineReactMonoWorker } from '@atolljs/react-island/worker';
|
|
190
|
+
import { emit } from '@atolljs/islands/worker';
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* ${P} — a real React component rendered INSIDE the worker. Mutations
|
|
194
|
+
* serialize to ops; the shell driver replays them into real DOM.
|
|
195
|
+
* Handlers receive the plain wire payload, not a SyntheticEvent.
|
|
196
|
+
*/
|
|
197
|
+
function ${P}({ label = '${name}' }: { label?: string }) {
|
|
198
|
+
const [count, setCount] = useState(0);
|
|
199
|
+
return (
|
|
200
|
+
<div>
|
|
201
|
+
<span>
|
|
202
|
+
{label}: {count}
|
|
203
|
+
</span>
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => {
|
|
206
|
+
const n = count + 1;
|
|
207
|
+
setCount(n);
|
|
208
|
+
emit('incremented', { count: n });
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
increment
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const worker = defineReactMonoWorker(${P});
|
|
218
|
+
`,
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
case 'vue':
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
path: `${dir}/${P}.vue`,
|
|
225
|
+
content: `<script setup lang="ts">
|
|
226
|
+
import { ref } from 'vue';
|
|
227
|
+
import { emit } from '@atolljs/islands/worker';
|
|
228
|
+
|
|
229
|
+
// Rendered inside the worker — DOM ops stream to the shell; emit()
|
|
230
|
+
// sends app events back to it.
|
|
231
|
+
defineProps<{ label?: string }>();
|
|
232
|
+
const count = ref(0);
|
|
233
|
+
function increment() {
|
|
234
|
+
count.value += 1;
|
|
235
|
+
emit('incremented', { count: count.value });
|
|
236
|
+
}
|
|
237
|
+
</script>
|
|
238
|
+
|
|
239
|
+
<template>
|
|
240
|
+
<div>
|
|
241
|
+
<span>{{ label ?? '${name}' }}: {{ count }}</span>
|
|
242
|
+
<button @click="increment">increment</button>
|
|
243
|
+
</div>
|
|
244
|
+
</template>
|
|
245
|
+
`,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
path: `${dir}/${name}.worker.ts`,
|
|
249
|
+
content: `import { defineVueMonoWorker } from '@atolljs/vue-island/worker';
|
|
250
|
+
import ${P} from './${P}.vue';
|
|
251
|
+
|
|
252
|
+
export const worker = defineVueMonoWorker(${P});
|
|
253
|
+
`,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
case 'solid':
|
|
257
|
+
return [
|
|
258
|
+
{
|
|
259
|
+
path: `${dir}/${name}.worker.tsx`,
|
|
260
|
+
content: `import { createSignal } from 'solid-js';
|
|
261
|
+
import { defineSolidMonoWorker } from '@atolljs/solid-island/worker';
|
|
262
|
+
import { emit } from '@atolljs/islands/worker';
|
|
263
|
+
|
|
264
|
+
/** ${P} — Solid rendering inside the worker; ops stream to the shell. */
|
|
265
|
+
function ${P}(props: { label?: string }) {
|
|
266
|
+
const [count, setCount] = createSignal(0);
|
|
267
|
+
return (
|
|
268
|
+
<div>
|
|
269
|
+
<span>
|
|
270
|
+
{props.label ?? '${name}'}: {count()}
|
|
271
|
+
</span>
|
|
272
|
+
<button
|
|
273
|
+
onClick={() => {
|
|
274
|
+
const n = count() + 1;
|
|
275
|
+
setCount(n);
|
|
276
|
+
emit('incremented', { count: n });
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
increment
|
|
280
|
+
</button>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const worker = defineSolidMonoWorker(${P});
|
|
286
|
+
`,
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
case 'svelte':
|
|
290
|
+
return [
|
|
291
|
+
{
|
|
292
|
+
path: `${dir}/${P}.svelte`,
|
|
293
|
+
content: `<script lang="ts">
|
|
294
|
+
import { emit } from '@atolljs/islands/worker';
|
|
295
|
+
|
|
296
|
+
// Rendered inside the worker — DOM ops stream to the shell.
|
|
297
|
+
let { label = '${name}' }: { label?: string } = $props();
|
|
298
|
+
let count = $state(0);
|
|
299
|
+
function increment() {
|
|
300
|
+
count += 1;
|
|
301
|
+
emit('incremented', { count });
|
|
302
|
+
}
|
|
303
|
+
</script>
|
|
304
|
+
|
|
305
|
+
<div>
|
|
306
|
+
<span>{label}: {count}</span>
|
|
307
|
+
<button onclick={increment}>increment</button>
|
|
308
|
+
</div>
|
|
309
|
+
`,
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
path: `${dir}/${name}.worker.ts`,
|
|
313
|
+
content: `import { defineSvelteMonoWorker } from '@atolljs/svelte-island/worker';
|
|
314
|
+
import ${P} from './${P}.svelte';
|
|
315
|
+
|
|
316
|
+
export const worker = defineSvelteMonoWorker(${P});
|
|
317
|
+
`,
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
case 'angular':
|
|
321
|
+
return [
|
|
322
|
+
{
|
|
323
|
+
path: `${dir}/${name}.worker.ts`,
|
|
324
|
+
content: `import { Component } from '@angular/core';
|
|
325
|
+
import { defineMonoWorker } from '@atolljs/islands/worker';
|
|
326
|
+
import { angularIslandApp } from '@atolljs/angular-island/worker';
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* ${P}Component — rendered inside the worker by Angular's own engine;
|
|
330
|
+
* ops stream to the shell driver on the main thread.
|
|
331
|
+
*/
|
|
332
|
+
@Component({
|
|
333
|
+
template: \`
|
|
334
|
+
<div>
|
|
335
|
+
<span>{{ label }}: {{ count }}</span>
|
|
336
|
+
<button (click)="increment()">increment</button>
|
|
337
|
+
</div>
|
|
338
|
+
\`,
|
|
339
|
+
})
|
|
340
|
+
export class ${P}Component {
|
|
341
|
+
label = '${name}';
|
|
342
|
+
count = 0;
|
|
343
|
+
increment() {
|
|
344
|
+
this.count += 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export const worker = defineMonoWorker(angularIslandApp(${P}Component));
|
|
349
|
+
`,
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
default:
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Shell-side usage hint printed after `add island` — one per framework. */
|
|
358
|
+
export function islandUsage(name: string, fw: Framework): string {
|
|
359
|
+
const P = pascal(name);
|
|
360
|
+
const ext = ISLAND_EXT[fw] ?? 'ts';
|
|
361
|
+
const factory = `() => new Worker(new URL('./islands/${name}.worker.${ext}', import.meta.url), { type: 'module' })`;
|
|
362
|
+
switch (fw) {
|
|
363
|
+
case 'react':
|
|
364
|
+
return `// mount it in your shell — worker factory stays inline so the bundler sees it
|
|
365
|
+
import { Island } from '@atolljs/react-island';
|
|
366
|
+
|
|
367
|
+
<Island app="${name}" worker={${factory}} onEvent={(n, p) => console.log(n, p)} />`;
|
|
368
|
+
case 'vue':
|
|
369
|
+
return `<script setup>
|
|
370
|
+
import { AtollIsland } from '@atolljs/vue-island';
|
|
371
|
+
const worker = ${factory};
|
|
372
|
+
</script>
|
|
373
|
+
|
|
374
|
+
<template>
|
|
375
|
+
<AtollIsland app="${name}" :worker="worker" />
|
|
376
|
+
</template>`;
|
|
377
|
+
case 'solid':
|
|
378
|
+
return `import { Island } from '@atolljs/solid-island';
|
|
379
|
+
|
|
380
|
+
<Island app="${name}" worker={${factory}} />`;
|
|
381
|
+
case 'svelte':
|
|
382
|
+
return `<script lang="ts">
|
|
383
|
+
import { island } from '@atolljs/svelte-island';
|
|
384
|
+
const worker = ${factory};
|
|
385
|
+
</script>
|
|
386
|
+
|
|
387
|
+
<div use:island={{ app: '${name}', worker }} />`;
|
|
388
|
+
case 'angular':
|
|
389
|
+
return `import { islandComponent } from '@atolljs/angular-island';
|
|
390
|
+
import type { ${P}Component } from './islands/${name}.worker';
|
|
391
|
+
|
|
392
|
+
export const ${P}Island = islandComponent<${P}Component>({
|
|
393
|
+
app: '${name}',
|
|
394
|
+
worker: ${factory},
|
|
395
|
+
});
|
|
396
|
+
// then use the generated component in a template — see docs/consumer Angular islands`;
|
|
397
|
+
default:
|
|
398
|
+
return '';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ── `atoll new` — full app scaffolds ───────────────────────────────────── */
|
|
403
|
+
|
|
404
|
+
const HEADERS = ` // SharedArrayBuffer is gated behind cross-origin isolation — these
|
|
405
|
+
// headers unlock it; islands/pools degrade gracefully without them.
|
|
406
|
+
server: {
|
|
407
|
+
headers: {
|
|
408
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
409
|
+
'Cross-Origin-Embedder-Policy': 'require-corp',
|
|
410
|
+
},
|
|
411
|
+
},`;
|
|
412
|
+
|
|
413
|
+
interface ScaffoldSpec {
|
|
414
|
+
deps: Record<string, string>;
|
|
415
|
+
devDeps: Record<string, string>;
|
|
416
|
+
vitePlugin?: { pkg: string; import_: string; call: string };
|
|
417
|
+
/** `atoll(…)` call args for the emitted config — e.g. solid needs its jsx runtime. */
|
|
418
|
+
atollCall?: string;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const SCAFFOLDS: Record<string, ScaffoldSpec> = {
|
|
422
|
+
react: {
|
|
423
|
+
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' },
|
|
424
|
+
devDeps: { vite: '^8.3.1', typescript: '^7.0.2', '@vitejs/plugin-react': '^6.1.1', '@atolljs/vite': '^0.1.0', '@types/react': '^19.3.0', '@types/react-dom': '^19.3.0' },
|
|
425
|
+
// `exclude` replaces the plugin's default /node_modules/ — keep it, and
|
|
426
|
+
// add *.worker.*: fast-refresh injects `/@react-refresh` (which reads
|
|
427
|
+
// `window` unguarded) into every transformed module, including worker
|
|
428
|
+
// entries served as ?worker_file — that crashes the worker in dev.
|
|
429
|
+
vitePlugin: { pkg: '@vitejs/plugin-react', import_: `import react from '@vitejs/plugin-react';`, call: 'react({ exclude: [/\\/node_modules\\//, /\\.worker\\./] })' },
|
|
430
|
+
},
|
|
431
|
+
vue: {
|
|
432
|
+
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' },
|
|
433
|
+
devDeps: { vite: '^8.3.1', typescript: '^7.0.2', '@vitejs/plugin-vue': '^6.0.9', '@atolljs/vite': '^0.1.0' },
|
|
434
|
+
vitePlugin: { pkg: '@vitejs/plugin-vue', import_: `import vue from '@vitejs/plugin-vue';`, call: 'vue()' },
|
|
435
|
+
},
|
|
436
|
+
solid: {
|
|
437
|
+
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' },
|
|
438
|
+
devDeps: { vite: '^8.3.1', typescript: '^7.0.2', 'vite-plugin-solid': '^2.11.14', '@atolljs/vite': '^0.1.0' },
|
|
439
|
+
atollCall: `atoll({ jsxImportSource: 'solid-js' })`,
|
|
440
|
+
vitePlugin: { pkg: 'vite-plugin-solid', import_: `import solid from 'vite-plugin-solid';`, call: 'solid()' },
|
|
441
|
+
},
|
|
442
|
+
svelte: {
|
|
443
|
+
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' },
|
|
444
|
+
devDeps: { vite: '^8.3.1', typescript: '^7.0.2', '@sveltejs/vite-plugin-svelte': '^7.3.1', '@atolljs/vite': '^0.1.0' },
|
|
445
|
+
vitePlugin: { pkg: '@sveltejs/vite-plugin-svelte', import_: `import { svelte } from '@sveltejs/vite-plugin-svelte';`, call: 'svelte()' },
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
export const SCAFFOLD_FRAMEWORKS = Object.keys(SCAFFOLDS) as readonly string[];
|
|
450
|
+
|
|
451
|
+
function pkgJson(appName: string, spec: ScaffoldSpec): string {
|
|
452
|
+
return (
|
|
453
|
+
JSON.stringify(
|
|
454
|
+
{
|
|
455
|
+
name: appName,
|
|
456
|
+
private: true,
|
|
457
|
+
type: 'module',
|
|
458
|
+
scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' },
|
|
459
|
+
dependencies: spec.deps,
|
|
460
|
+
devDependencies: spec.devDeps,
|
|
461
|
+
},
|
|
462
|
+
null,
|
|
463
|
+
2,
|
|
464
|
+
) + '\n'
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const TSCONFIGS: Record<string, object> = {
|
|
469
|
+
react: {
|
|
470
|
+
compilerOptions: {
|
|
471
|
+
target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler',
|
|
472
|
+
lib: ['ESNext', 'DOM', 'DOM.Iterable'], jsx: 'react-jsx',
|
|
473
|
+
strict: true, noEmit: true, isolatedModules: true, skipLibCheck: true,
|
|
474
|
+
allowImportingTsExtensions: true,
|
|
475
|
+
},
|
|
476
|
+
include: ['src'],
|
|
477
|
+
},
|
|
478
|
+
vue: {
|
|
479
|
+
compilerOptions: {
|
|
480
|
+
target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler',
|
|
481
|
+
lib: ['ESNext', 'DOM', 'DOM.Iterable'],
|
|
482
|
+
strict: true, noEmit: true, isolatedModules: true, skipLibCheck: true,
|
|
483
|
+
allowImportingTsExtensions: true,
|
|
484
|
+
},
|
|
485
|
+
include: ['src'],
|
|
486
|
+
},
|
|
487
|
+
solid: {
|
|
488
|
+
compilerOptions: {
|
|
489
|
+
target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler',
|
|
490
|
+
lib: ['ESNext', 'DOM', 'DOM.Iterable'], jsx: 'preserve', jsxImportSource: 'solid-js',
|
|
491
|
+
strict: true, noEmit: true, isolatedModules: true, skipLibCheck: true,
|
|
492
|
+
allowImportingTsExtensions: true,
|
|
493
|
+
},
|
|
494
|
+
include: ['src'],
|
|
495
|
+
},
|
|
496
|
+
svelte: {
|
|
497
|
+
compilerOptions: {
|
|
498
|
+
target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler',
|
|
499
|
+
lib: ['ESNext', 'DOM', 'DOM.Iterable'],
|
|
500
|
+
strict: true, noEmit: true, isolatedModules: true, skipLibCheck: true,
|
|
501
|
+
allowImportingTsExtensions: true, types: ['vite/client'],
|
|
502
|
+
},
|
|
503
|
+
include: ['src'],
|
|
504
|
+
},
|
|
505
|
+
node: {
|
|
506
|
+
compilerOptions: {
|
|
507
|
+
target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler',
|
|
508
|
+
// DOM+WebWorker libs — @atolljs/node types pool workers against the
|
|
509
|
+
// DOM Worker surface; without them its shipped source won't typecheck.
|
|
510
|
+
lib: ['ESNext', 'DOM', 'WebWorker'], types: ['node'],
|
|
511
|
+
strict: true, noEmit: true, isolatedModules: true, skipLibCheck: true,
|
|
512
|
+
allowImportingTsExtensions: true,
|
|
513
|
+
},
|
|
514
|
+
include: ['src'],
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
function viteConfig(fw: string): string {
|
|
519
|
+
const spec = SCAFFOLDS[fw];
|
|
520
|
+
const plugin = spec.vitePlugin;
|
|
521
|
+
return `import { defineConfig } from 'vite';
|
|
522
|
+
${plugin?.import_ ?? ''}
|
|
523
|
+
import atoll from '@atolljs/vite';
|
|
524
|
+
|
|
525
|
+
export default defineConfig({
|
|
526
|
+
// atoll() serves worker entries as esbuild bundles (rebuild + respawn on
|
|
527
|
+
// change) — browser-plugin transforms like react fast-refresh can't leak
|
|
528
|
+
// into worker code.
|
|
529
|
+
plugins: [${plugin?.call ?? ''}, ${spec.atollCall ?? 'atoll()'}],
|
|
530
|
+
${
|
|
531
|
+
plugin
|
|
532
|
+
? ` // Worker bundles run their own rolldown pass — fresh plugin instances
|
|
533
|
+
// let the worker entry compile framework files too (worker-island apps).
|
|
534
|
+
worker: { plugins: () => [${plugin.call}] },
|
|
535
|
+
`
|
|
536
|
+
: ''
|
|
537
|
+
}${HEADERS}
|
|
538
|
+
});
|
|
539
|
+
`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const INDEX_HTML = (title: string, entry: string) => `<!doctype html>
|
|
543
|
+
<html lang="en">
|
|
544
|
+
<head>
|
|
545
|
+
<meta charset="UTF-8" />
|
|
546
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
547
|
+
<title>${title}</title>
|
|
548
|
+
</head>
|
|
549
|
+
<body>
|
|
550
|
+
<div id="root"></div>
|
|
551
|
+
<script type="module" src="/${entry}"></script>
|
|
552
|
+
</body>
|
|
553
|
+
</html>
|
|
554
|
+
`;
|
|
555
|
+
|
|
556
|
+
/** All files for `atoll new <dir> --framework <fw>` (browser vite apps). */
|
|
557
|
+
export function scaffoldFiles(appName: string, fw: Framework): OutFile[] {
|
|
558
|
+
const spec = SCAFFOLDS[fw];
|
|
559
|
+
if (!spec) return [];
|
|
560
|
+
const files: OutFile[] = [
|
|
561
|
+
{ path: 'package.json', content: pkgJson(appName, spec) },
|
|
562
|
+
{ path: 'tsconfig.json', content: JSON.stringify(TSCONFIGS[fw], null, 2) + '\n' },
|
|
563
|
+
{ path: 'vite.config.ts', content: viteConfig(fw) },
|
|
564
|
+
{ path: '.npmrc', content: NPMRC },
|
|
565
|
+
{ path: '.gitignore', content: GITIGNORE },
|
|
566
|
+
{ path: 'index.html', content: INDEX_HTML(appName, `src/main.${ISLAND_EXT[fw]}`) },
|
|
567
|
+
];
|
|
568
|
+
const counter = islandFiles('counter', fw);
|
|
569
|
+
switch (fw) {
|
|
570
|
+
case 'react':
|
|
571
|
+
files.push(
|
|
572
|
+
...counter,
|
|
573
|
+
{
|
|
574
|
+
path: 'src/main.tsx',
|
|
575
|
+
content: `import { createRoot } from 'react-dom/client';
|
|
576
|
+
import { App } from './App';
|
|
577
|
+
|
|
578
|
+
createRoot(document.getElementById('root')!).render(<App />);
|
|
579
|
+
`,
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
path: 'src/App.tsx',
|
|
583
|
+
content: `import { Island } from '@atolljs/react-island';
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The counter island renders inside a worker — the main thread only replays
|
|
587
|
+
* its DOM ops. New islands: \`npx atoll add island <name>\`.
|
|
588
|
+
*/
|
|
589
|
+
export function App() {
|
|
590
|
+
return (
|
|
591
|
+
<main style={{ fontFamily: 'system-ui', padding: '2rem' }}>
|
|
592
|
+
<h1>${appName}</h1>
|
|
593
|
+
<p>This counter renders in a worker thread.</p>
|
|
594
|
+
<Island
|
|
595
|
+
app="counter"
|
|
596
|
+
worker={() =>
|
|
597
|
+
new Worker(new URL('./islands/counter.worker.tsx', import.meta.url), { type: 'module' })
|
|
598
|
+
}
|
|
599
|
+
onEvent={(name, payload) => console.log('island event:', name, payload)}
|
|
600
|
+
/>
|
|
601
|
+
</main>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
`,
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
break;
|
|
608
|
+
case 'vue':
|
|
609
|
+
files.push(
|
|
610
|
+
...counter,
|
|
611
|
+
{ path: 'src/env.d.ts', content: `declare module '*.vue';\n` },
|
|
612
|
+
{
|
|
613
|
+
path: 'src/main.ts',
|
|
614
|
+
content: `import { createApp } from 'vue';
|
|
615
|
+
import App from './App.vue';
|
|
616
|
+
|
|
617
|
+
createApp(App).mount('#root');
|
|
618
|
+
`,
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
path: 'src/App.vue',
|
|
622
|
+
content: `<script setup lang="ts">
|
|
623
|
+
import { AtollIsland } from '@atolljs/vue-island';
|
|
624
|
+
|
|
625
|
+
// The counter island renders inside a worker — the main thread replays ops.
|
|
626
|
+
const worker = () =>
|
|
627
|
+
new Worker(new URL('./islands/counter.worker.ts', import.meta.url), { type: 'module' });
|
|
628
|
+
</script>
|
|
629
|
+
|
|
630
|
+
<template>
|
|
631
|
+
<main style="font-family: system-ui; padding: 2rem">
|
|
632
|
+
<h1>${appName}</h1>
|
|
633
|
+
<p>This counter renders in a worker thread.</p>
|
|
634
|
+
<AtollIsland app="counter" :worker="worker" />
|
|
635
|
+
</main>
|
|
636
|
+
</template>
|
|
637
|
+
`,
|
|
638
|
+
},
|
|
639
|
+
);
|
|
640
|
+
break;
|
|
641
|
+
case 'solid':
|
|
642
|
+
files.push(
|
|
643
|
+
...counter,
|
|
644
|
+
{
|
|
645
|
+
path: 'src/main.tsx',
|
|
646
|
+
content: `import { render } from 'solid-js/web';
|
|
647
|
+
import { App } from './App';
|
|
648
|
+
|
|
649
|
+
render(() => <App />, document.getElementById('root')!);
|
|
650
|
+
`,
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
path: 'src/App.tsx',
|
|
654
|
+
content: `import { Island } from '@atolljs/solid-island';
|
|
655
|
+
|
|
656
|
+
/** The counter island renders inside a worker — the main thread replays ops. */
|
|
657
|
+
export function App() {
|
|
658
|
+
return (
|
|
659
|
+
<main style={{ 'font-family': 'system-ui', padding: '2rem' }}>
|
|
660
|
+
<h1>${appName}</h1>
|
|
661
|
+
<p>This counter renders in a worker thread.</p>
|
|
662
|
+
<Island
|
|
663
|
+
app="counter"
|
|
664
|
+
worker={() =>
|
|
665
|
+
new Worker(new URL('./islands/counter.worker.tsx', import.meta.url), { type: 'module' })
|
|
666
|
+
}
|
|
667
|
+
/>
|
|
668
|
+
</main>
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
`,
|
|
672
|
+
},
|
|
673
|
+
);
|
|
674
|
+
break;
|
|
675
|
+
case 'svelte':
|
|
676
|
+
files.push(
|
|
677
|
+
...counter,
|
|
678
|
+
{
|
|
679
|
+
path: 'src/main.ts',
|
|
680
|
+
content: `import { mount } from 'svelte';
|
|
681
|
+
import App from './App.svelte';
|
|
682
|
+
|
|
683
|
+
mount(App, { target: document.getElementById('root')! });
|
|
684
|
+
`,
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
path: 'src/App.svelte',
|
|
688
|
+
content: `<script lang="ts">
|
|
689
|
+
import { island } from '@atolljs/svelte-island';
|
|
690
|
+
|
|
691
|
+
// The counter island renders inside a worker — the main thread replays ops.
|
|
692
|
+
const worker = () =>
|
|
693
|
+
new Worker(new URL('./islands/counter.worker.ts', import.meta.url), { type: 'module' });
|
|
694
|
+
</script>
|
|
695
|
+
|
|
696
|
+
<main style="font-family: system-ui; padding: 2rem">
|
|
697
|
+
<h1>${appName}</h1>
|
|
698
|
+
<p>This counter renders in a worker thread.</p>
|
|
699
|
+
<div use:island={{ app: 'counter', worker }} />
|
|
700
|
+
</main>
|
|
701
|
+
`,
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
return files;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** `atoll new --framework node` — a tsx service with a worker pool. */
|
|
710
|
+
export function nodeScaffoldFiles(appName: string): OutFile[] {
|
|
711
|
+
return [
|
|
712
|
+
{
|
|
713
|
+
path: 'package.json',
|
|
714
|
+
content:
|
|
715
|
+
JSON.stringify(
|
|
716
|
+
{
|
|
717
|
+
name: appName,
|
|
718
|
+
private: true,
|
|
719
|
+
type: 'module',
|
|
720
|
+
scripts: {
|
|
721
|
+
dev: 'tsx watch src/main.ts',
|
|
722
|
+
start: 'tsx src/main.ts',
|
|
723
|
+
bundle:
|
|
724
|
+
'esbuild src/atoll/tasks.worker.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/tasks.worker.js',
|
|
725
|
+
},
|
|
726
|
+
dependencies: { '@atolljs/core': '^0.1.0', '@atolljs/node': '^0.1.0', zod: '^4.6.5' },
|
|
727
|
+
devDependencies: { esbuild: '^0.28.2', tsx: '^4.23.15', typescript: '^7.0.2', '@types/node': '^26.6.3' },
|
|
728
|
+
},
|
|
729
|
+
null,
|
|
730
|
+
2,
|
|
731
|
+
) + '\n',
|
|
732
|
+
},
|
|
733
|
+
{ path: 'tsconfig.json', content: JSON.stringify(TSCONFIGS.node, null, 2) + '\n' },
|
|
734
|
+
{ path: '.npmrc', content: NPMRC },
|
|
735
|
+
{ path: '.gitignore', content: GITIGNORE },
|
|
736
|
+
{
|
|
737
|
+
path: 'src/atoll/tasks.memory.ts',
|
|
738
|
+
content: memoryContract('tasks'),
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
path: 'src/atoll/tasks.worker.ts',
|
|
742
|
+
content: workerEntry('tasks', {
|
|
743
|
+
host: 'node',
|
|
744
|
+
memoryImport: './tasks.memory',
|
|
745
|
+
memoryName: 'tasksMemory',
|
|
746
|
+
demoMemoryWrite: true,
|
|
747
|
+
}),
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
path: 'src/atoll/tasks.ts',
|
|
751
|
+
content: workerClientFile('tasks', {
|
|
752
|
+
host: 'node',
|
|
753
|
+
memoryImport: './tasks.memory',
|
|
754
|
+
memoryName: 'tasksMemory',
|
|
755
|
+
distUrl: '../../dist/tasks.worker.js',
|
|
756
|
+
}),
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
path: 'src/main.ts',
|
|
760
|
+
content: `import { tasks, tasksPool } from './atoll/tasks';
|
|
761
|
+
import { tasksMemory } from './atoll/tasks.memory';
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* ${appName} — a worker pool behind a typed client. The echo task runs off
|
|
765
|
+
* the main thread; shared memory is readable here with zero dispatch.
|
|
766
|
+
*/
|
|
767
|
+
const heard = await tasks.echo('hello from main');
|
|
768
|
+
console.log(heard);
|
|
769
|
+
console.log('summary (shared memory):', tasksMemory.state.summary.read());
|
|
770
|
+
|
|
771
|
+
await tasksPool.close();
|
|
772
|
+
`,
|
|
773
|
+
},
|
|
774
|
+
];
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/* ── framework bindings over a pool client (`add <fw> worker <name>`) ────── */
|
|
778
|
+
|
|
779
|
+
interface MemRef {
|
|
780
|
+
/** Import specifier relative to the generated file. */
|
|
781
|
+
import_: string;
|
|
782
|
+
/** Contract export name, e.g. `appMemory`. */
|
|
783
|
+
export_: string;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const UI_BINDINGS: readonly Framework[] = [
|
|
787
|
+
'react',
|
|
788
|
+
'nextjs',
|
|
789
|
+
'vue',
|
|
790
|
+
'solid',
|
|
791
|
+
'svelte',
|
|
792
|
+
'angular',
|
|
793
|
+
];
|
|
794
|
+
|
|
795
|
+
export const hasWorkerBindings = (fw: Framework): boolean => UI_BINDINGS.includes(fw);
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The framework-facing half of `add worker` — a hooks/signals adapter module
|
|
799
|
+
* beside the pool client, so components never touch the client directly.
|
|
800
|
+
* Returns null for frameworks without a binding idiom (node, vanilla…).
|
|
801
|
+
*/
|
|
802
|
+
export function workerBindingsFile(
|
|
803
|
+
name: string,
|
|
804
|
+
fw: Framework,
|
|
805
|
+
dir: string,
|
|
806
|
+
mem: MemRef | null,
|
|
807
|
+
): OutFile | null {
|
|
808
|
+
const c = camel(name);
|
|
809
|
+
const P = pascal(name);
|
|
810
|
+
const memImport = mem ? `import { ${mem.export_} } from '${mem.import_}';\n` : '';
|
|
811
|
+
// 'signals.progress' exists on CLI-generated contracts — emitted as a
|
|
812
|
+
// commented example so a hand-authored contract never breaks the build.
|
|
813
|
+
const watchLine = (hook: string, decl = 'const') =>
|
|
814
|
+
mem ? `\n // ${decl} progress = ${hook}(${mem.export_}, 'signals.progress');` : '';
|
|
815
|
+
switch (fw) {
|
|
816
|
+
case 'react':
|
|
817
|
+
return {
|
|
818
|
+
path: `${dir}/use${P}.ts`,
|
|
819
|
+
content: `import { useTask${mem ? ', useSharedValue' : ''} } from '@atolljs/react';
|
|
820
|
+
import { ${c} } from './${name}';
|
|
821
|
+
${memImport}
|
|
822
|
+
/**
|
|
823
|
+
* React bindings for the ${name} pool — call inside components. useTask
|
|
824
|
+
* wraps a call in { data, pending, settled, elapsedMs, error } with
|
|
825
|
+
* latest-wins run()/runOnce(); useSharedValue parks on Atomics.
|
|
826
|
+
*/
|
|
827
|
+
export function use${P}() {
|
|
828
|
+
const echo = useTask(${c}.echo);${watchLine('useSharedValue')}
|
|
829
|
+
return { echo };
|
|
830
|
+
}
|
|
831
|
+
`,
|
|
832
|
+
};
|
|
833
|
+
case 'nextjs':
|
|
834
|
+
return {
|
|
835
|
+
path: `${dir}/${name}.tsx`,
|
|
836
|
+
content: `'use client';
|
|
837
|
+
import { useTask${mem ? ', useSharedValue' : ''} } from '@atolljs/nextjs';
|
|
838
|
+
import { ${c} } from './${name}';
|
|
839
|
+
${memImport}
|
|
840
|
+
/**
|
|
841
|
+
* Client component over the ${name} pool — 'use client' keeps the hooks
|
|
842
|
+
* browser-side; SSR-safe because the pool spawns lazily on first call.
|
|
843
|
+
*/
|
|
844
|
+
export function ${P}() {
|
|
845
|
+
const echo = useTask(${c}.echo);${watchLine('useSharedValue')}
|
|
846
|
+
return (
|
|
847
|
+
<button onClick={() => echo.run('ping')}>
|
|
848
|
+
{echo.data ?? (echo.pending ? 'working\\u2026' : '${name}')}
|
|
849
|
+
</button>
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
`,
|
|
853
|
+
};
|
|
854
|
+
case 'vue':
|
|
855
|
+
return {
|
|
856
|
+
path: `${dir}/use${P}.ts`,
|
|
857
|
+
content: `import { useTask${mem ? ', useSharedValue' : ''} } from '@atolljs/vue';
|
|
858
|
+
import { ${c} } from './${name}';
|
|
859
|
+
${memImport}
|
|
860
|
+
/** Vue composable over the ${name} pool — refs, ready for <script setup>. */
|
|
861
|
+
export function use${P}() {
|
|
862
|
+
const echo = useTask(${c}.echo);${watchLine('useSharedValue')}
|
|
863
|
+
return { echo };
|
|
864
|
+
}
|
|
865
|
+
`,
|
|
866
|
+
};
|
|
867
|
+
case 'solid':
|
|
868
|
+
return {
|
|
869
|
+
path: `${dir}/use${P}.ts`,
|
|
870
|
+
content: `import { createTask${mem ? ', createSharedValue' : ''} } from '@atolljs/solidjs';
|
|
871
|
+
import { ${c} } from './${name}';
|
|
872
|
+
${memImport}
|
|
873
|
+
/** Solid bindings over the ${name} pool — accessors, call inside setup. */
|
|
874
|
+
export function create${P}() {
|
|
875
|
+
const echo = createTask(${c}.echo);${watchLine('createSharedValue')}
|
|
876
|
+
return { echo };
|
|
877
|
+
}
|
|
878
|
+
`,
|
|
879
|
+
};
|
|
880
|
+
case 'svelte':
|
|
881
|
+
// *.svelte.ts — runes ($effect inside taskState/sharedValue) only
|
|
882
|
+
// compile in svelte-module files.
|
|
883
|
+
return {
|
|
884
|
+
path: `${dir}/${name}.svelte.ts`,
|
|
885
|
+
content: `import { taskState${mem ? ', sharedValue' : ''} } from '@atolljs/svelte';
|
|
886
|
+
import { ${c} } from './${name}';
|
|
887
|
+
${memImport}
|
|
888
|
+
/** Svelte bindings over the ${name} pool — call inside components. */
|
|
889
|
+
export function ${c}State() {
|
|
890
|
+
const echo = taskState(${c}.echo);${watchLine('sharedValue')}
|
|
891
|
+
return { echo };
|
|
892
|
+
}
|
|
893
|
+
`,
|
|
894
|
+
};
|
|
895
|
+
case 'angular':
|
|
896
|
+
return {
|
|
897
|
+
path: `${dir}/${name}.facade.ts`,
|
|
898
|
+
content: `import { Injectable } from '@angular/core';
|
|
899
|
+
import { taskState${mem ? ', sharedValue' : ''} } from '@atolljs/angular';
|
|
900
|
+
import { ${c} } from './${name}';
|
|
901
|
+
${memImport}
|
|
902
|
+
/**
|
|
903
|
+
* Angular facade over the ${name} pool — field initializers run in the
|
|
904
|
+
* injection context, so the signal subscription unbinds on destroy.
|
|
905
|
+
*/
|
|
906
|
+
@Injectable({ providedIn: 'root' })
|
|
907
|
+
export class ${P}Facade {
|
|
908
|
+
readonly echo = taskState(${c}.echo);${watchLine('sharedValue', 'readonly')}
|
|
909
|
+
readonly run = (message: string) => this.echo.run(message);
|
|
910
|
+
}
|
|
911
|
+
`,
|
|
912
|
+
};
|
|
913
|
+
default:
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/* ── islands — facade variant: contract module + lazy import ────────────── */
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* `add island <name> facade` — the contract-module pattern: a shell-safe
|
|
922
|
+
* `${name}.island.ts` exports { app, worker }; the lazy facade imports it,
|
|
923
|
+
* so the worker entry (and the app's dependency graph) only loads on mount.
|
|
924
|
+
*/
|
|
925
|
+
export function islandFacadeFiles(name: string, fw: Framework, dir = 'src/islands'): OutFile[] {
|
|
926
|
+
const P = pascal(name);
|
|
927
|
+
const ext = ISLAND_EXT[fw] ?? 'ts';
|
|
928
|
+
const contract = (appLine: string): string => `/**
|
|
929
|
+
* ${name} island contract — shell-safe: the app reference + the worker
|
|
930
|
+
* factory. Never import ./${name}.worker.${ext} here — definePolyWorker
|
|
931
|
+
* installs worker-side globals on evaluation.
|
|
932
|
+
*/
|
|
933
|
+
${appLine}
|
|
934
|
+
export const worker = () =>
|
|
935
|
+
new Worker(new URL('./${name}.worker.${ext}', import.meta.url), { type: 'module' });
|
|
936
|
+
`;
|
|
937
|
+
switch (fw) {
|
|
938
|
+
case 'react':
|
|
939
|
+
case 'solid': {
|
|
940
|
+
const isReact = fw === 'react';
|
|
941
|
+
const sig = isReact ? 'useState' : 'createSignal';
|
|
942
|
+
const poly = isReact ? 'defineReactPolyWorker' : 'defineSolidPolyWorker';
|
|
943
|
+
const pkg = `@atolljs/${fw}-island/worker`;
|
|
944
|
+
return [
|
|
945
|
+
{
|
|
946
|
+
path: `${dir}/${name}.app.tsx`,
|
|
947
|
+
content: `import { ${sig} } from '${isReact ? 'react' : 'solid-js'}';
|
|
948
|
+
import { islandApp } from '@atolljs/islands/worker';
|
|
949
|
+
import { emit } from '${pkg}';
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* ${P}App — a real ${isReact ? 'React' : 'Solid'} component rendered INSIDE
|
|
953
|
+
* the worker. islandApp stamps the registry key (minification-proof); state
|
|
954
|
+
* never leaves the worker, DOM ops stream to the shell driver.
|
|
955
|
+
*/
|
|
956
|
+
export const ${P}App = islandApp('${name}', function ${P}(${
|
|
957
|
+
isReact ? `{ label = '${name}' }: { label?: string }` : `props: { label?: string }`
|
|
958
|
+
}) {
|
|
959
|
+
${isReact ? 'const [count, setCount] = useState(0);' : 'const [count, setCount] = createSignal(0);'}
|
|
960
|
+
return (
|
|
961
|
+
<button
|
|
962
|
+
onClick={() => {
|
|
963
|
+
const n = ${isReact ? 'count' : 'count()'} + 1;
|
|
964
|
+
setCount(n);
|
|
965
|
+
emit('incremented', { count: n });
|
|
966
|
+
}}
|
|
967
|
+
>
|
|
968
|
+
${isReact ? `{label}: {count}` : `{props.label ?? '${name}'}: {count()}`}
|
|
969
|
+
</button>
|
|
970
|
+
);
|
|
971
|
+
});
|
|
972
|
+
`,
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
path: `${dir}/${name}.worker.${ext}`,
|
|
976
|
+
content: `import { ${poly} } from '${pkg}';
|
|
977
|
+
import { ${P}App } from './${name}.app';
|
|
978
|
+
|
|
979
|
+
/** One worker, many islands — add apps to the registry. */
|
|
980
|
+
export const worker = ${poly}({ apps: { ${name}: ${P}App } });
|
|
981
|
+
`,
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
path: `${dir}/${name}.island.ts`,
|
|
985
|
+
content: contract(`export { ${P}App as app } from './${name}.app';`),
|
|
986
|
+
},
|
|
987
|
+
];
|
|
988
|
+
}
|
|
989
|
+
case 'vue': {
|
|
990
|
+
const [component] = islandFiles(name, 'vue', dir); // reuse the SFC
|
|
991
|
+
return [
|
|
992
|
+
component,
|
|
993
|
+
{
|
|
994
|
+
path: `${dir}/${name}.worker.ts`,
|
|
995
|
+
content: `import { defineVuePolyWorker } from '@atolljs/vue-island/worker';
|
|
996
|
+
import ${P} from './${P}.vue';
|
|
997
|
+
|
|
998
|
+
/** One worker, many islands — add apps to the registry. */
|
|
999
|
+
export const worker = defineVuePolyWorker({ apps: { ${name}: ${P} } });
|
|
1000
|
+
`,
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
path: `${dir}/${name}.island.ts`,
|
|
1004
|
+
content: contract(`export const app = '${name}';`),
|
|
1005
|
+
},
|
|
1006
|
+
];
|
|
1007
|
+
}
|
|
1008
|
+
case 'svelte': {
|
|
1009
|
+
const [component] = islandFiles(name, 'svelte', dir); // reuse the SFC
|
|
1010
|
+
return [
|
|
1011
|
+
component,
|
|
1012
|
+
{
|
|
1013
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1014
|
+
content: `import { defineSveltePolyWorker } from '@atolljs/svelte-island/worker';
|
|
1015
|
+
import ${P} from './${P}.svelte';
|
|
1016
|
+
|
|
1017
|
+
/** One worker, many islands — add apps to the registry. */
|
|
1018
|
+
export const worker = defineSveltePolyWorker({ apps: { ${name}: ${P} } });
|
|
1019
|
+
`,
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
path: `${dir}/${name}.island.ts`,
|
|
1023
|
+
content: contract(`export const app = '${name}';`),
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
path: `${dir}/${P}Island.svelte`,
|
|
1027
|
+
content: `<script lang="ts">
|
|
1028
|
+
import { island } from '@atolljs/svelte-island';
|
|
1029
|
+
import { app, worker } from './${name}.island';
|
|
1030
|
+
|
|
1031
|
+
// The facade component — attrs serialize to worker props on mount.
|
|
1032
|
+
let { label = '${name}' }: { label?: string } = $props();
|
|
1033
|
+
</script>
|
|
1034
|
+
|
|
1035
|
+
<div use:island={{ app, worker, props: { label } }} />
|
|
1036
|
+
`,
|
|
1037
|
+
},
|
|
1038
|
+
];
|
|
1039
|
+
}
|
|
1040
|
+
case 'angular':
|
|
1041
|
+
return [
|
|
1042
|
+
{
|
|
1043
|
+
path: `${dir}/${name}.component.ts`,
|
|
1044
|
+
content: `import { Component, input, output } from '@angular/core';
|
|
1045
|
+
import { AngularIsland } from '@atolljs/angular-island/worker';
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* ${P}Component — rendered inside the worker by Angular's own engine.
|
|
1049
|
+
* @AngularIsland stamps the registry key and registers the component, so
|
|
1050
|
+
* defineAngularPolyWorker() collects it with no hand-kept apps map.
|
|
1051
|
+
*/
|
|
1052
|
+
@AngularIsland('${name}')
|
|
1053
|
+
@Component({
|
|
1054
|
+
standalone: true,
|
|
1055
|
+
template: \`
|
|
1056
|
+
<button (click)="increment()">{{ label() }}: {{ count }}</button>
|
|
1057
|
+
\`,
|
|
1058
|
+
})
|
|
1059
|
+
export class ${P}Component {
|
|
1060
|
+
label = input('${name}');
|
|
1061
|
+
/** Root output() fields bridge to the shell's island event channel. */
|
|
1062
|
+
incremented = output<{ count: number }>();
|
|
1063
|
+
count = 0;
|
|
1064
|
+
increment() {
|
|
1065
|
+
this.count += 1;
|
|
1066
|
+
this.incremented.emit({ count: this.count });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
`,
|
|
1070
|
+
},
|
|
1071
|
+
{
|
|
1072
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1073
|
+
content: `import { defineAngularPolyWorker } from '@atolljs/angular-island/worker';
|
|
1074
|
+
import './${name}.component'; // registers via @AngularIsland
|
|
1075
|
+
|
|
1076
|
+
/** One worker, many islands — decorated components self-register. */
|
|
1077
|
+
export const worker = defineAngularPolyWorker();
|
|
1078
|
+
`,
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
path: `${dir}/${name}.island.ts`,
|
|
1082
|
+
content: `import { islandComponent } from '@atolljs/angular-island';
|
|
1083
|
+
import type { ${P}Component } from './${name}.component';
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* ${name} island facade — a standalone component typed off the worker
|
|
1087
|
+
* component's input()/output() contract. Use <${name}-island> in templates.
|
|
1088
|
+
*/
|
|
1089
|
+
export const ${P}Island = islandComponent<${P}Component>({
|
|
1090
|
+
app: '${name}',
|
|
1091
|
+
selector: '${name}-island',
|
|
1092
|
+
worker: () =>
|
|
1093
|
+
new Worker(new URL('./${name}.worker.ts', import.meta.url), { type: 'module' }),
|
|
1094
|
+
});
|
|
1095
|
+
`,
|
|
1096
|
+
},
|
|
1097
|
+
];
|
|
1098
|
+
default:
|
|
1099
|
+
return [];
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Shell-side usage hint for the facade variant. */
|
|
1104
|
+
export function islandFacadeUsage(name: string, fw: Framework): string {
|
|
1105
|
+
const P = pascal(name);
|
|
1106
|
+
switch (fw) {
|
|
1107
|
+
case 'react':
|
|
1108
|
+
return `import { Suspense } from 'react';
|
|
1109
|
+
import { lazyIsland } from '@atolljs/react-island';
|
|
1110
|
+
|
|
1111
|
+
const ${P}Island = lazyIsland(() => import('./islands/${name}.island'));
|
|
1112
|
+
|
|
1113
|
+
<Suspense fallback={null}>
|
|
1114
|
+
<${P}Island label="${name}" onEvent={(n, p) => console.log(n, p)} />
|
|
1115
|
+
</Suspense>`;
|
|
1116
|
+
case 'vue':
|
|
1117
|
+
return `<script setup>
|
|
1118
|
+
import { lazyIsland } from '@atolljs/vue-island';
|
|
1119
|
+
const ${P}Island = lazyIsland(() => import('./islands/${name}.island'));
|
|
1120
|
+
</script>
|
|
1121
|
+
|
|
1122
|
+
<template>
|
|
1123
|
+
<${P}Island label="${name}" />
|
|
1124
|
+
</template>`;
|
|
1125
|
+
case 'solid':
|
|
1126
|
+
return `import { lazyIsland } from '@atolljs/solid-island';
|
|
1127
|
+
|
|
1128
|
+
const ${P}Island = lazyIsland(() => import('./islands/${name}.island'));
|
|
1129
|
+
|
|
1130
|
+
<${P}Island label="${name}" />`;
|
|
1131
|
+
case 'svelte':
|
|
1132
|
+
return `<script lang="ts">
|
|
1133
|
+
import ${P}Island from './islands/${P}Island.svelte';
|
|
1134
|
+
</script>
|
|
1135
|
+
|
|
1136
|
+
<${P}Island label="${name}" />`;
|
|
1137
|
+
case 'angular':
|
|
1138
|
+
return `import { ${P}Island } from './islands/${name}.island';
|
|
1139
|
+
// add ${P}Island to the host component's imports, then:
|
|
1140
|
+
// <${name}-island [props]="{ label: '${name}' }" (incremented)="onIncremented($event)" />`;
|
|
1141
|
+
default:
|
|
1142
|
+
return '';
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/* ── nestjs (`atoll add nestjs <level> <name>`) ─────────────────────────── */
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* The pool boundary module + worker entry — shared by `service` (class
|
|
1150
|
+
* facade), `module` (boundary only), and referenced by `method`.
|
|
1151
|
+
* The module is symmetric: the app imports it for DI, each worker boots it
|
|
1152
|
+
* via runAtollWorker.
|
|
1153
|
+
*/
|
|
1154
|
+
export function nestjsModuleFiles(
|
|
1155
|
+
name: string,
|
|
1156
|
+
dir: string,
|
|
1157
|
+
opts: { service?: string; mem?: MemRef | null },
|
|
1158
|
+
): OutFile[] {
|
|
1159
|
+
const P = pascal(name);
|
|
1160
|
+
const memImport = opts.mem ? `import { ${opts.mem.export_} } from '${opts.mem.import_}';\n` : '';
|
|
1161
|
+
const serviceImport = opts.service
|
|
1162
|
+
? `import { ${opts.service} } from './${name}.service';\n`
|
|
1163
|
+
: '';
|
|
1164
|
+
const memLine = opts.mem
|
|
1165
|
+
? ` sharedMemory: ${opts.mem.export_},\n`
|
|
1166
|
+
: ` // message-only — add sharedMemory: <contract> to give the pool a buffer\n`;
|
|
1167
|
+
const providers = opts.service
|
|
1168
|
+
? ` providers: [${opts.service}],\n exports: [${opts.service}, AtollModule],\n`
|
|
1169
|
+
: '';
|
|
1170
|
+
return [
|
|
1171
|
+
{
|
|
1172
|
+
path: `${dir}/${name}.module.ts`,
|
|
1173
|
+
content: `import { Module } from '@nestjs/common';
|
|
1174
|
+
import { Worker } from 'node:worker_threads';
|
|
1175
|
+
import { AtollModule } from '@atolljs/nestjs';
|
|
1176
|
+
${serviceImport}${memImport}
|
|
1177
|
+
/**
|
|
1178
|
+
* ${name} pool boundary — the feature module owns its worker domain.
|
|
1179
|
+
* Imported by AppModule for the API thread (registerPool spawns workers)
|
|
1180
|
+
* and by the worker entry (runAtollWorker boots the same module in-thread).
|
|
1181
|
+
*/
|
|
1182
|
+
@Module({
|
|
1183
|
+
imports: [
|
|
1184
|
+
AtollModule.registerPool({
|
|
1185
|
+
name: '${name}',
|
|
1186
|
+
// webpack emits the worker entry as its own chunk — keep the URL inline
|
|
1187
|
+
worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1188
|
+
${memLine} poolSize: 'auto',
|
|
1189
|
+
}),
|
|
1190
|
+
],
|
|
1191
|
+
${providers}})
|
|
1192
|
+
export class ${P}Module {}
|
|
1193
|
+
`,
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1197
|
+
content: `// Worker entry — boots a Nest application context inside the worker and
|
|
1198
|
+
// binds every @AtollService/@AtollTask provider in ${P}Module.
|
|
1199
|
+
import { runAtollWorker } from '@atolljs/nestjs/worker';
|
|
1200
|
+
import { ${P}Module } from './${name}.module';
|
|
1201
|
+
|
|
1202
|
+
void runAtollWorker(${P}Module);
|
|
1203
|
+
`,
|
|
1204
|
+
},
|
|
1205
|
+
];
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* `add nestjs service <name>` — the @AtollService facade: every method
|
|
1210
|
+
* dispatches to the pool, consumers inject it with zero Atoll imports.
|
|
1211
|
+
* Emits the service plus the pool module + worker entry it runs in.
|
|
1212
|
+
*/
|
|
1213
|
+
export function nestjsServiceFiles(
|
|
1214
|
+
name: string,
|
|
1215
|
+
dir: string,
|
|
1216
|
+
mem: MemRef | null,
|
|
1217
|
+
): OutFile[] {
|
|
1218
|
+
const P = pascal(name);
|
|
1219
|
+
const memImport = mem ? `import { ${mem.export_} } from '${mem.import_}';\n` : '';
|
|
1220
|
+
const memWrite = mem
|
|
1221
|
+
? `\n // shared memory crosses without a message — pick a real field:\n // ${mem.export_}.signals.progress.write(100);`
|
|
1222
|
+
: '';
|
|
1223
|
+
return [
|
|
1224
|
+
{
|
|
1225
|
+
path: `${dir}/${name}.service.ts`,
|
|
1226
|
+
content: `import { Injectable } from '@nestjs/common';
|
|
1227
|
+
import { threadId } from 'node:worker_threads';
|
|
1228
|
+
import { AtollService } from '@atolljs/nestjs/decorators';
|
|
1229
|
+
${memImport}
|
|
1230
|
+
/**
|
|
1231
|
+
* Service-level facade — the class IS the interop surface. Consumers inject
|
|
1232
|
+
* it like any provider; every call dispatches to the '${name}' pool as a
|
|
1233
|
+
* ${P}Service.method task and the body runs on the worker's DI'd instance.
|
|
1234
|
+
*/
|
|
1235
|
+
@Injectable()
|
|
1236
|
+
@AtollService({ pool: '${name}' })
|
|
1237
|
+
export class ${P}Service {
|
|
1238
|
+
/** Runs inside the worker — the return value crosses postMessage. */
|
|
1239
|
+
async echo(input: string) {${memWrite}
|
|
1240
|
+
return { heard: input, worker: threadId };
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
`,
|
|
1244
|
+
},
|
|
1245
|
+
...nestjsModuleFiles(name, dir, { service: `${P}Service`, mem }),
|
|
1246
|
+
];
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* `add nestjs method <name>` — method-level offload via @AtollTask: only
|
|
1251
|
+
* decorated methods dispatch; the rest of the class runs on the API thread.
|
|
1252
|
+
*/
|
|
1253
|
+
export function nestjsMethodFiles(name: string, dir: string): OutFile[] {
|
|
1254
|
+
const P = pascal(name);
|
|
1255
|
+
return [
|
|
1256
|
+
{
|
|
1257
|
+
path: `${dir}/${name}.service.ts`,
|
|
1258
|
+
content: `import { Injectable } from '@nestjs/common';
|
|
1259
|
+
import { threadId } from 'node:worker_threads';
|
|
1260
|
+
import { AtollTask } from '@atolljs/nestjs/decorators';
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Method-level offload — only decorated methods dispatch to the pool; the
|
|
1264
|
+
* rest of the class stays on the API thread. The body executes inside the
|
|
1265
|
+
* worker's own Nest context on the DI-resolved instance.
|
|
1266
|
+
*/
|
|
1267
|
+
@Injectable()
|
|
1268
|
+
export class ${P}Service {
|
|
1269
|
+
@AtollTask({ pool: '${name}' })
|
|
1270
|
+
async run(input: string) {
|
|
1271
|
+
return { heard: input, worker: threadId };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** Not decorated — plain main-thread method. */
|
|
1275
|
+
describe() {
|
|
1276
|
+
return '${name} service, partial offload';
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
`,
|
|
1280
|
+
},
|
|
1281
|
+
];
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* `add nestjs housed <name>` — a route subtree that exists ONLY inside pool
|
|
1286
|
+
* workers: dedicated HTTP worker (NestFactory + serveHttp), a worker-side
|
|
1287
|
+
* API module, a controller, and the main-side pool registration.
|
|
1288
|
+
*/
|
|
1289
|
+
export function nestjsHousedFiles(name: string, dir: string): OutFile[] {
|
|
1290
|
+
const P = pascal(name);
|
|
1291
|
+
return [
|
|
1292
|
+
{
|
|
1293
|
+
path: `${dir}/${name}-atoll.module.ts`,
|
|
1294
|
+
content: `// Main-side boundary — registers the '${name}' pool the housed API
|
|
1295
|
+
// runs in. Message-only by default; to share another pool's buffer, wrap
|
|
1296
|
+
// the factory:
|
|
1297
|
+
// worker: withSharedBuffer(
|
|
1298
|
+
// () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1299
|
+
// () => getAtollPool('<other-pool>')?.sharedBuffer,
|
|
1300
|
+
// )
|
|
1301
|
+
import { Module } from '@nestjs/common';
|
|
1302
|
+
import { Worker } from 'node:worker_threads';
|
|
1303
|
+
import { AtollModule } from '@atolljs/nestjs';
|
|
1304
|
+
|
|
1305
|
+
@Module({
|
|
1306
|
+
imports: [
|
|
1307
|
+
AtollModule.registerPool({
|
|
1308
|
+
name: '${name}',
|
|
1309
|
+
worker: () => new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1310
|
+
poolSize: 2,
|
|
1311
|
+
}),
|
|
1312
|
+
],
|
|
1313
|
+
})
|
|
1314
|
+
export class ${P}AtollModule {}
|
|
1315
|
+
`,
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1319
|
+
content: `// Dedicated HTTP worker — NOT a task worker: it serves requests, so no
|
|
1320
|
+
// runAtollWorker. serveHttp binds an internal 127.0.0.1 port and announces
|
|
1321
|
+
// it via the HTTP_PORT handshake; a respawned worker re-announces.
|
|
1322
|
+
import 'reflect-metadata';
|
|
1323
|
+
import { NestFactory } from '@nestjs/core';
|
|
1324
|
+
import { serveHttp } from '@atolljs/node/http';
|
|
1325
|
+
import { ${P}ApiModule } from './${name}-api.module';
|
|
1326
|
+
|
|
1327
|
+
void (async () => {
|
|
1328
|
+
const app = await NestFactory.create(${P}ApiModule, { logger: ['warn', 'error'] });
|
|
1329
|
+
await app.init();
|
|
1330
|
+
serveHttp(app.getHttpServer(), { listen: 0 });
|
|
1331
|
+
})();
|
|
1332
|
+
`,
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
path: `${dir}/${name}-api.module.ts`,
|
|
1336
|
+
content: `// Worker-side module — pure HTTP surface (controllers/providers only).
|
|
1337
|
+
// No registerPool import: pools only exist on the main thread.
|
|
1338
|
+
import { Module } from '@nestjs/common';
|
|
1339
|
+
import { ${P}Controller } from './${name}.controller';
|
|
1340
|
+
|
|
1341
|
+
@Module({ controllers: [${P}Controller] })
|
|
1342
|
+
export class ${P}ApiModule {}
|
|
1343
|
+
`,
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
path: `${dir}/${name}.controller.ts`,
|
|
1347
|
+
content: `import { Controller, Get } from '@nestjs/common';
|
|
1348
|
+
import { threadId } from 'node:worker_threads';
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Housed controller — exists ONLY inside '${name}' pool workers. The main
|
|
1352
|
+
* app never sees these routes: it proxies /api/${name}/* into the workers'
|
|
1353
|
+
* internal listeners, so decorators, DI, and guards execute off-thread.
|
|
1354
|
+
*/
|
|
1355
|
+
@Controller('api/${name}')
|
|
1356
|
+
export class ${P}Controller {
|
|
1357
|
+
/** Which pool worker owns this request. */
|
|
1358
|
+
@Get('whoami')
|
|
1359
|
+
whoami() {
|
|
1360
|
+
return { worker: threadId };
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
`,
|
|
1364
|
+
},
|
|
1365
|
+
];
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** Gateway wiring printed after `add nestjs housed` — belongs in main.ts. */
|
|
1369
|
+
export function nestjsHousedWiring(name: string): string {
|
|
1370
|
+
return `main.ts — proxy the prefix into the pool:
|
|
1371
|
+
|
|
1372
|
+
import { getAtollPool } from '@atolljs/nestjs';
|
|
1373
|
+
import { proxyToWorker, workerHttpPorts } from '@atolljs/node/http';
|
|
1374
|
+
|
|
1375
|
+
const pool = getAtollPool('${name}');
|
|
1376
|
+
if (pool) {
|
|
1377
|
+
const tracker = workerHttpPorts(pool);
|
|
1378
|
+
app.use('/api/${name}', proxyToWorker({
|
|
1379
|
+
pool,
|
|
1380
|
+
tracker,
|
|
1381
|
+
to: '/api/${name}', // express strips the mount — restore it in-worker
|
|
1382
|
+
}));
|
|
1383
|
+
}`;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/* ── nextjs (`atoll add nextjs <kind> <name>`) ──────────────────────────── */
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* `add nextjs route <name> [task|client]` — a Node-runtime route handler
|
|
1390
|
+
* backed by a node:worker_threads pool. `task` = TaskRegistry contract
|
|
1391
|
+
* dispatch; `client` = defineWorker + typed workerClient.
|
|
1392
|
+
*/
|
|
1393
|
+
export function nextjsRouteFiles(
|
|
1394
|
+
name: string,
|
|
1395
|
+
dir: string,
|
|
1396
|
+
variant: 'task' | 'client',
|
|
1397
|
+
): OutFile[] {
|
|
1398
|
+
const c = camel(name);
|
|
1399
|
+
const P = pascal(name);
|
|
1400
|
+
const workerFactory = `createNodeWorker(
|
|
1401
|
+
new Worker(new URL('./${name}.worker.ts', import.meta.url)),
|
|
1402
|
+
)`;
|
|
1403
|
+
if (variant === 'task') {
|
|
1404
|
+
return [
|
|
1405
|
+
{
|
|
1406
|
+
path: `${dir}/${name}.contract.ts`,
|
|
1407
|
+
content: `import { z } from 'zod';
|
|
1408
|
+
import { defineSharedMemory, field, type TaskContract } from '@atolljs/core';
|
|
1409
|
+
|
|
1410
|
+
/** Pool-owned shared state — the route handler reads it with zero dispatch. */
|
|
1411
|
+
export const ${c}Memory = defineSharedMemory({
|
|
1412
|
+
jobsDone: field.number(),
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
const result = z.object({
|
|
1416
|
+
echo: z.string(),
|
|
1417
|
+
ms: z.number(),
|
|
1418
|
+
jobsDone: z.number(),
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
/** Executed inside a worker via TaskRegistry. */
|
|
1422
|
+
export const ${P}Job: TaskContract<[input?: string], z.infer<typeof result>> = {
|
|
1423
|
+
taskId: '${name}.run',
|
|
1424
|
+
resultSchema: result,
|
|
1425
|
+
};
|
|
1426
|
+
`,
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1430
|
+
content: `// Worker entry — the shim MUST be first: it binds self = parentPort
|
|
1431
|
+
// before workerBootstrap wires INIT_MEMORY / EXECUTE_TASK.
|
|
1432
|
+
import '@atolljs/node/shim';
|
|
1433
|
+
import '@atolljs/core/worker/workerBootstrap';
|
|
1434
|
+
import { TaskRegistry } from '@atolljs/core';
|
|
1435
|
+
import { ${c}Memory, ${P}Job } from './${name}.contract';
|
|
1436
|
+
|
|
1437
|
+
TaskRegistry.register(${P}Job, (input = '${name}') => {
|
|
1438
|
+
const t0 = performance.now();
|
|
1439
|
+
const jobs = ${c}Memory.jobsDone.read() + 1;
|
|
1440
|
+
${c}Memory.jobsDone.write(jobs);
|
|
1441
|
+
return { echo: \`worker heard: \${input}\`, ms: performance.now() - t0, jobsDone: jobs };
|
|
1442
|
+
});
|
|
1443
|
+
`,
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
path: `${dir}/pool.ts`,
|
|
1447
|
+
content: `import { Worker } from 'node:worker_threads';
|
|
1448
|
+
import { createNodePool, createNodeWorker } from '@atolljs/node';
|
|
1449
|
+
import { ${c}Memory, ${P}Job } from './${name}.contract';
|
|
1450
|
+
|
|
1451
|
+
const create = () => {
|
|
1452
|
+
const pool = createNodePool({
|
|
1453
|
+
sharedMemory: ${c}Memory,
|
|
1454
|
+
poolSize: 'auto',
|
|
1455
|
+
tasks: { run: ${P}Job },
|
|
1456
|
+
createWorker: () =>
|
|
1457
|
+
${workerFactory},
|
|
1458
|
+
});
|
|
1459
|
+
// Read memory via this getter — Next compiles module graphs per route
|
|
1460
|
+
// layer, so a fresh contract import can be a second, unbound instance.
|
|
1461
|
+
return { pool, memory: ${c}Memory };
|
|
1462
|
+
};
|
|
1463
|
+
|
|
1464
|
+
// Module scope = process scope; globalThis survives dev-mode HMR reloads.
|
|
1465
|
+
export const get${P} = (): ReturnType<typeof create> => {
|
|
1466
|
+
const g = globalThis as { __atoll${P}?: ReturnType<typeof create> };
|
|
1467
|
+
return (g.__atoll${P} ??= create());
|
|
1468
|
+
};
|
|
1469
|
+
`,
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
path: `${dir}/route.ts`,
|
|
1473
|
+
content: `import { NextResponse } from 'next/server';
|
|
1474
|
+
import { get${P} } from './pool';
|
|
1475
|
+
|
|
1476
|
+
// worker_threads need the Node.js runtime — Edge cannot spawn them.
|
|
1477
|
+
export const runtime = 'nodejs';
|
|
1478
|
+
export const dynamic = 'force-dynamic';
|
|
1479
|
+
|
|
1480
|
+
/** POST /api/${name} {"input": "..."} — dispatch the task into the pool. */
|
|
1481
|
+
export async function POST(req: Request) {
|
|
1482
|
+
const body = await req.json().catch(() => ({}));
|
|
1483
|
+
return NextResponse.json(await get${P}().pool.run(body?.input));
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** GET /api/${name} — read shared state on this thread, no dispatch. */
|
|
1487
|
+
export async function GET() {
|
|
1488
|
+
return NextResponse.json({ jobsDone: get${P}().memory.jobsDone.read() });
|
|
1489
|
+
}
|
|
1490
|
+
`,
|
|
1491
|
+
},
|
|
1492
|
+
];
|
|
1493
|
+
}
|
|
1494
|
+
// 'client' variant — defineWorker + typed workerClient
|
|
1495
|
+
return [
|
|
1496
|
+
{
|
|
1497
|
+
path: `${dir}/${name}.contract.ts`,
|
|
1498
|
+
content: `import { defineSharedMemory, field } from '@atolljs/core';
|
|
1499
|
+
|
|
1500
|
+
/** Pool-owned shared state — the route handler reads it with zero dispatch. */
|
|
1501
|
+
export const ${c}Memory = defineSharedMemory({
|
|
1502
|
+
jobsDone: field.number(),
|
|
1503
|
+
});
|
|
1504
|
+
`,
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
path: `${dir}/${name}.worker.ts`,
|
|
1508
|
+
content: `// Worker entry — shim first, then defineWorker installs the message loop.
|
|
1509
|
+
import '@atolljs/node/shim';
|
|
1510
|
+
import { defineWorker, serviceMethod } from '@atolljs/core';
|
|
1511
|
+
import { z } from 'zod';
|
|
1512
|
+
import { ${c}Memory } from './${name}.contract';
|
|
1513
|
+
|
|
1514
|
+
const echo = serviceMethod({
|
|
1515
|
+
def: { argsSchema: z.tuple([z.string()]), resultSchema: z.string() },
|
|
1516
|
+
run: (message) => {
|
|
1517
|
+
${c}Memory.jobsDone.write(${c}Memory.jobsDone.read() + 1);
|
|
1518
|
+
return \`worker heard: \${message}\`;
|
|
1519
|
+
},
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
export const ${c}Worker = defineWorker({
|
|
1523
|
+
sharedMemory: ${c}Memory,
|
|
1524
|
+
methods: { echo },
|
|
1525
|
+
});
|
|
1526
|
+
export type ${P}Worker = typeof ${c}Worker;
|
|
1527
|
+
`,
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
path: `${dir}/pool.ts`,
|
|
1531
|
+
content: `import { Worker } from 'node:worker_threads';
|
|
1532
|
+
import { workerClient } from '@atolljs/core';
|
|
1533
|
+
import { createNodePool, createNodeWorker } from '@atolljs/node';
|
|
1534
|
+
import { ${c}Memory } from './${name}.contract';
|
|
1535
|
+
// Type-only — worker code must not leak into the server bundle.
|
|
1536
|
+
import type { ${P}Worker } from './${name}.worker';
|
|
1537
|
+
|
|
1538
|
+
const create = () => {
|
|
1539
|
+
const pool = createNodePool({
|
|
1540
|
+
sharedMemory: ${c}Memory,
|
|
1541
|
+
poolSize: 'auto',
|
|
1542
|
+
createWorker: () =>
|
|
1543
|
+
${workerFactory},
|
|
1544
|
+
});
|
|
1545
|
+
// Read memory via this getter — Next compiles module graphs per route
|
|
1546
|
+
// layer, so a fresh contract import can be a second, unbound instance.
|
|
1547
|
+
return { pool, client: workerClient<${P}Worker>(pool), memory: ${c}Memory };
|
|
1548
|
+
};
|
|
1549
|
+
|
|
1550
|
+
// Module scope = process scope; globalThis survives dev-mode HMR reloads.
|
|
1551
|
+
export const get${P} = (): ReturnType<typeof create> => {
|
|
1552
|
+
const g = globalThis as { __atoll${P}?: ReturnType<typeof create> };
|
|
1553
|
+
return (g.__atoll${P} ??= create());
|
|
1554
|
+
};
|
|
1555
|
+
`,
|
|
1556
|
+
},
|
|
1557
|
+
{
|
|
1558
|
+
path: `${dir}/route.ts`,
|
|
1559
|
+
content: `import { NextResponse } from 'next/server';
|
|
1560
|
+
import { get${P} } from './pool';
|
|
1561
|
+
|
|
1562
|
+
// worker_threads need the Node.js runtime — Edge cannot spawn them.
|
|
1563
|
+
export const runtime = 'nodejs';
|
|
1564
|
+
export const dynamic = 'force-dynamic';
|
|
1565
|
+
|
|
1566
|
+
/** POST /api/${name} {"input": "..."} — a typed client call into the pool. */
|
|
1567
|
+
export async function POST(req: Request) {
|
|
1568
|
+
const body = await req.json().catch(() => ({}));
|
|
1569
|
+
return NextResponse.json({
|
|
1570
|
+
echo: await get${P}().client.echo(String(body?.input ?? '${name}')),
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** GET /api/${name} — read shared state on this thread, no dispatch. */
|
|
1575
|
+
export async function GET() {
|
|
1576
|
+
return NextResponse.json({ jobsDone: get${P}().memory.jobsDone.read() });
|
|
1577
|
+
}
|
|
1578
|
+
`,
|
|
1579
|
+
},
|
|
1580
|
+
];
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* `add nextjs component <name>` — a 'use client' component bound to the
|
|
1585
|
+
* ${name} pool via useTask/useSharedValue. Expects the client + contract
|
|
1586
|
+
* from `add nextjs worker <name>` beside it.
|
|
1587
|
+
*/
|
|
1588
|
+
export function nextjsComponentFiles(
|
|
1589
|
+
name: string,
|
|
1590
|
+
dir: string,
|
|
1591
|
+
mem: MemRef | null,
|
|
1592
|
+
): OutFile[] {
|
|
1593
|
+
const c = camel(name);
|
|
1594
|
+
const P = pascal(name);
|
|
1595
|
+
return [
|
|
1596
|
+
{
|
|
1597
|
+
path: `${dir}/${name}.tsx`,
|
|
1598
|
+
content: `'use client';
|
|
1599
|
+
import { useTask${mem ? ', useSharedValue' : ''} } from '@atolljs/nextjs';
|
|
1600
|
+
import { ${c} } from './${name}';
|
|
1601
|
+
${mem ? `import { ${mem.export_} } from '${mem.import_}';\n` : ''}
|
|
1602
|
+
/**
|
|
1603
|
+
* ${P} — a client component over the ${name} pool. Field reads return
|
|
1604
|
+
* undefined until the contract binds on the client (SSR-safe).
|
|
1605
|
+
*/
|
|
1606
|
+
export function ${P}() {
|
|
1607
|
+
const echo = useTask(${c}.echo);${
|
|
1608
|
+
mem
|
|
1609
|
+
? `\n const progress = useSharedValue(${mem.export_}, 'signals.progress');`
|
|
1610
|
+
: ''
|
|
1611
|
+
}
|
|
1612
|
+
return (
|
|
1613
|
+
<button onClick={() => echo.run('ping')}>
|
|
1614
|
+
{echo.data ?? (echo.pending ? 'working\\u2026' : '${name}')}
|
|
1615
|
+
</button>
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
`,
|
|
1619
|
+
},
|
|
1620
|
+
];
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* `add nextjs instrumentation <name>` — the server bootstrap hook: warms the
|
|
1625
|
+
* named route pool at boot instead of inside the first unlucky request.
|
|
1626
|
+
*/
|
|
1627
|
+
export function nextjsInstrumentationFile(name: string, dir = 'src'): OutFile {
|
|
1628
|
+
const P = pascal(name);
|
|
1629
|
+
return {
|
|
1630
|
+
path: `${dir}/instrumentation.ts`,
|
|
1631
|
+
content: `/**
|
|
1632
|
+
* Next.js server bootstrap — runs once when the Node.js runtime boots.
|
|
1633
|
+
* Dynamic imports keep node:worker_threads out of the edge/browser bundles.
|
|
1634
|
+
*/
|
|
1635
|
+
export async function register() {
|
|
1636
|
+
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
|
|
1637
|
+
const { get${P} } = await import('./app/api/${name}/pool');
|
|
1638
|
+
get${P}(); // spawn workers + bind the buffer during startup, not per request
|
|
1639
|
+
}
|
|
1640
|
+
`,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
|