@atolljs/angular 0.1.0

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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @atolljs/angular
2
+
3
+ Angular bindings for `@atolljs/core` — framework adapter only, no domain code.
4
+ Signal-based; call in an injection context (field initializer or constructor)
5
+ so subscriptions are released on destroy.
6
+
7
+ - `observableSignal(source)` — any `ObservableValue` as a `Signal`.
8
+ - `sharedValue(memory, key)` — a shared-memory field as a `Signal`.
9
+ - `taskState(task)` — `{ state }` Signal of the task snapshot plus `run`/`runOnce`.
10
+
11
+ The consuming app composes these with its own contracts and tasks.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@atolljs/angular",
3
+ "version": "0.1.0",
4
+ "description": "Angular bindings — observableSignal, sharedValue, taskState",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "peerDependencies": {
16
+ "@atolljs/core": "0.1.0",
17
+ "@angular/core": "^22.2.0"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/jwhenry3/atolljs",
22
+ "directory": "packages/angular"
23
+ }
24
+ }
@@ -0,0 +1,199 @@
1
+ import {
2
+ APP_INITIALIZER,
3
+ DestroyRef,
4
+ Inject,
5
+ InjectionToken,
6
+ Injector,
7
+ NgModule,
8
+ type ModuleWithProviders,
9
+ type Provider,
10
+ type Type,
11
+ } from '@angular/core';
12
+ import {
13
+ buildAtollPool,
14
+ getAtollPoolToken,
15
+ atollProviders,
16
+ type AtollFeature,
17
+ type AtollPoolDeclaration,
18
+ type AtollPoolSpec,
19
+ type AtollProvideOptions,
20
+ } from './provideAtoll';
21
+
22
+ /**
23
+ * A pool whose declaration resolves from injected deps during app init — the
24
+ * async form of AtollPoolDeclaration. The name stays static so the
25
+ * `ATOLL_POOL:<name>` injection token can be declared up front.
26
+ */
27
+ export interface AtollPoolAsyncDecl {
28
+ /** Registry name — declares the ATOLL_POOL:<name> token before the factory resolves. */
29
+ name?: string;
30
+ /** Provider tokens passed to useFactory, in order. */
31
+ inject?: unknown[];
32
+ /**
33
+ * Receives the injected deps; returns a AtollPoolDeclaration or a Promise
34
+ * of one. A `name` on the result is ignored — the static `name` field owns
35
+ * the registry slot.
36
+ */
37
+ useFactory: (...args: any[]) => AtollPoolDeclaration | Promise<AtollPoolDeclaration>;
38
+ }
39
+
40
+ /** AtollModule.forRootAsync options. */
41
+ export interface AtollModuleAsyncOptions {
42
+ pools?: AtollPoolAsyncDecl[];
43
+ }
44
+
45
+ /** AtollModule.registerPoolAsync options — BullModule.registerQueueAsync analog. */
46
+ export interface AtollPoolAsyncOptions {
47
+ /** Registry name — static so `ATOLL_POOL:<name>` stays injectable while the factory resolves. */
48
+ name?: string;
49
+ /**
50
+ * Kept for Nest-parity/documentation: Angular's ModuleWithProviders has no
51
+ * `imports` slot, so this only documents where `inject` deps come from —
52
+ * they resolve through whichever injector imports the module (NgModule
53
+ * providers flatten into the importing injector). List dep-providing
54
+ * modules on the consuming @NgModule's `imports`.
55
+ */
56
+ imports?: Array<Type<unknown> | ModuleWithProviders<unknown> | unknown[]>;
57
+ /** Provider tokens passed to useFactory, in order. */
58
+ inject?: unknown[];
59
+ /** Receives the injected deps; returns a AtollPoolSpec (no `name` — the static field owns it) or a Promise of one. */
60
+ useFactory: (...args: any[]) => AtollPoolSpec | Promise<AtollPoolSpec>;
61
+ }
62
+
63
+ /**
64
+ * Mutable slot an async initializer fills before bootstrap completes — the
65
+ * pool factory reads it lazily, after APP_INITIALIZER has resolved.
66
+ */
67
+ interface AsyncDeclHolder {
68
+ decl?: AtollPoolDeclaration;
69
+ }
70
+
71
+ /**
72
+ * Two-stage async pool. Angular provider factories can't await, so an
73
+ * APP_INITIALIZER (multi:true) resolves useFactory into the holder during
74
+ * bootstrap — `inject` deps arrive through that record's factory signature.
75
+ * The initializer then forces the spawn itself and hooks terminate() on the
76
+ * injector's DestroyRef, because the sync path's ENVIRONMENT_INITIALIZER runs
77
+ * at injector creation — too early to await anything.
78
+ */
79
+ function asyncPoolProviders(
80
+ name: string,
81
+ options: { inject?: unknown[]; useFactory: (...args: any[]) => unknown },
82
+ ): Provider[] {
83
+ const declHolder = new InjectionToken<AsyncDeclHolder>(`ATOLL_POOL_DECL:${name}`);
84
+ return [
85
+ { provide: declHolder, useValue: {} satisfies AsyncDeclHolder },
86
+ {
87
+ provide: getAtollPoolToken(name),
88
+ useFactory: (holder: AsyncDeclHolder) => {
89
+ if (!holder.decl) {
90
+ throw new Error(
91
+ `Atoll pool "${name}" injected before its async declaration resolved — ` +
92
+ 'bootstrap blocks on APP_INITIALIZER; under TestBed await ' +
93
+ 'ApplicationInitStatus.donePromise first.',
94
+ );
95
+ }
96
+ return buildAtollPool({ ...holder.decl, name });
97
+ },
98
+ deps: [declHolder],
99
+ },
100
+ {
101
+ provide: APP_INITIALIZER,
102
+ multi: true,
103
+ useFactory: (holder: AsyncDeclHolder, injector: Injector, ...deps: unknown[]) => {
104
+ const destroyRef = injector.get(DestroyRef);
105
+ return async () => {
106
+ const resolved = (await options.useFactory(...deps)) as AtollPoolDeclaration;
107
+ holder.decl = { ...resolved, name };
108
+ const pool = injector.get(getAtollPoolToken(name)); // eager spawn once resolved
109
+ destroyRef.onDestroy(() => pool.terminate());
110
+ };
111
+ },
112
+ deps: [declHolder, Injector, ...(options.inject ?? [])],
113
+ },
114
+ ];
115
+ }
116
+
117
+ /**
118
+ * NgModule form of provideAtoll — the NestJS binding's vocabulary
119
+ * (forRoot / forRootAsync / registerPool / registerPoolAsync) for apps still
120
+ * on NgModule bootstrap:
121
+ *
122
+ * @NgModule({
123
+ * imports: [
124
+ * AtollModule.forRoot(), // or forRoot({ pools: [...] })
125
+ * IncidentsModule, // imports AtollModule.registerPool({ name: 'incidents', client: incidents })
126
+ * ],
127
+ * })
128
+ * export class AppModule {}
129
+ *
130
+ * The standalone provider API — `provideAtoll` + `injectAtollPool` — remains
131
+ * the primary interface (bootstrapApplication providers, route-level pools);
132
+ * AtollModule exists for codebases that organize DI around NgModules. Both
133
+ * paths register the same `ATOLL_POOL:<name>` tokens, spawn eagerly, and
134
+ * terminate pools when the owning injector is destroyed.
135
+ */
136
+ @NgModule()
137
+ export class AtollModule {
138
+ /**
139
+ * Root registration — equivalent providers to `provideAtoll(options,
140
+ * ...features)`, imported once by the root module.
141
+ */
142
+ static forRoot(
143
+ options?: AtollProvideOptions,
144
+ ...features: AtollFeature[]
145
+ ): ModuleWithProviders<AtollModule> {
146
+ return { ngModule: AtollModule, providers: atollProviders(options?.pools ?? [], features) };
147
+ }
148
+
149
+ /**
150
+ * Async root registration — each pool's declaration resolves through an
151
+ * APP_INITIALIZER before bootstrap completes. The `name` field is static
152
+ * so the `ATOLL_POOL:<name>` token stays injectable.
153
+ */
154
+ static forRootAsync(options: AtollModuleAsyncOptions): ModuleWithProviders<AtollModule> {
155
+ const providers: Provider[] = (options.pools ?? []).flatMap((decl, i) =>
156
+ asyncPoolProviders(decl.name ?? (i === 0 ? 'default' : `pool${i}`), decl),
157
+ );
158
+ return { ngModule: AtollModule, providers };
159
+ }
160
+
161
+ /**
162
+ * Registers one pool inside whichever module imports it — the
163
+ * `provideAtoll({ pools: [decl] })` equivalent scoped to the importing
164
+ * NgModule: pool config lives in the feature module that owns the worker.
165
+ */
166
+ static registerPool(decl: AtollPoolDeclaration): ModuleWithProviders<AtollModule> {
167
+ return { ngModule: AtollModule, providers: atollProviders([decl]) };
168
+ }
169
+
170
+ /**
171
+ * Async pool registration — the name is static so `ATOLL_POOL:<name>` stays
172
+ * injectable, while the rest of the spec resolves from injected deps:
173
+ *
174
+ * AtollModule.registerPoolAsync({
175
+ * name: 'incidents',
176
+ * useFactory: (config: PoolConfig) => Promise.resolve({
177
+ * worker: () => new Worker(new URL('./incidents.worker.ts', import.meta.url)),
178
+ * sharedMemory: incidentsMemory,
179
+ * poolSize: config.poolSize,
180
+ * }),
181
+ * inject: [PoolConfig],
182
+ * })
183
+ */
184
+ static registerPoolAsync(options: AtollPoolAsyncOptions): ModuleWithProviders<AtollModule> {
185
+ return {
186
+ ngModule: AtollModule,
187
+ providers: asyncPoolProviders(options.name ?? 'default', options),
188
+ };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Constructor-parameter form of injectAtollPool — injects the pool registered
194
+ * under `name` into an @Injectable() (or component/directive) constructor:
195
+ *
196
+ * constructor(@InjectAtollPool('incidents') private readonly pool: IncidentsClient) {}
197
+ */
198
+ export const InjectAtollPool = (name = 'default'): ParameterDecorator =>
199
+ Inject(getAtollPoolToken(name));
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { DestroyRef, inject, signal, type Signal } from '@angular/core';
2
+ import { observe, toTask } from '@atolljs/core';
3
+ import type { AsyncTask, ObservableValue, PathConnector, SharedMemory, SharedSpec, SpecPath, SliceOptions, TaskSnapshot } from '@atolljs/core';
4
+
5
+ function unhook(stop: () => void) {
6
+ inject(DestroyRef, { optional: true })?.onDestroy(stop);
7
+ }
8
+
9
+ /**
10
+ * Wrap any sdk ObservableValue in a Signal. Call in an injection context
11
+ * (component field initializer or constructor) so the subscription is
12
+ * released on destroy; without one the subscription lives forever.
13
+ */
14
+ export function observableSignal<T>(source: ObservableValue<T>): Signal<T> {
15
+ const value = signal<T>(source.get());
16
+ unhook(source.subscribe((v) => value.set(v)));
17
+ return value.asReadonly();
18
+ }
19
+
20
+ /**
21
+ * Bind one shared-memory field to a Signal. Stays `undefined` until the
22
+ * contract is bound and the field written; updates on every write.
23
+ */
24
+ export function sharedValue<S extends SharedSpec, K extends SpecPath<S>>(memory: SharedMemory<S>, key: K): Signal<(PathConnector<S, K> extends { read(): infer T } ? T : never) | undefined>;
25
+ export function sharedValue<S extends SharedSpec, K extends SpecPath<S>, Sel>(
26
+ memory: SharedMemory<S>,
27
+ key: K,
28
+ select: (value: PathConnector<S, K> extends { read(): infer T } ? T : never) => Sel,
29
+ options?: SliceOptions<Sel>
30
+ ): Signal<Sel | undefined>;
31
+ export function sharedValue<S extends SharedSpec, K extends SpecPath<S>, Sel>(
32
+ memory: SharedMemory<S>,
33
+ key: K,
34
+ select?: (value: never) => Sel,
35
+ options?: SliceOptions<Sel>
36
+ ) {
37
+ return observableSignal(observe(memory, key, select as never, options as never));
38
+ }
39
+
40
+ /** Bind an AsyncTask or plain async fn (via toTask): `{ state }` is a Signal of its snapshot; `run`/`runOnce` trigger it. */
41
+ export function taskState<R>(source: AsyncTask<void, R> | (() => Promise<R>)): { state: Signal<TaskSnapshot<R>>; run: AsyncTask<void, R>['run']; runOnce: AsyncTask<void, R>['runOnce'] };
42
+ export function taskState<A, R>(source: AsyncTask<A, R> | ((input: A) => Promise<R>)): { state: Signal<TaskSnapshot<R>>; run: AsyncTask<A, R>['run']; runOnce: AsyncTask<A, R>['runOnce'] };
43
+ export function taskState<A, R>(source: AsyncTask<A, R> | ((input: A) => Promise<R>)) {
44
+ const task = toTask(source);
45
+ return { state: observableSignal(task), run: task.run, runOnce: task.runOnce };
46
+ }
47
+
48
+ export { provideAtoll, injectAtollPool, getAtollPoolToken } from './provideAtoll';
49
+ export type { AtollPoolDeclaration, AtollPoolSpec, AtollProvideOptions, AtollFeature } from './provideAtoll';
50
+ export { AtollModule, InjectAtollPool } from './atollModule';
51
+ export type { AtollModuleAsyncOptions, AtollPoolAsyncOptions, AtollPoolAsyncDecl } from './atollModule';
@@ -0,0 +1,201 @@
1
+ import {
2
+ DestroyRef,
3
+ ENVIRONMENT_INITIALIZER,
4
+ inject,
5
+ InjectionToken,
6
+ makeEnvironmentProviders,
7
+ type EnvironmentProviders,
8
+ type Provider,
9
+ } from '@angular/core';
10
+ import {
11
+ WorkerPool,
12
+ type SharedAccess,
13
+ type SharedMemory,
14
+ type SharedSpec,
15
+ type TaskMap,
16
+ type WorkerPoolConfig,
17
+ } from '@atolljs/core';
18
+
19
+ /**
20
+ * The lifecycle surface every connectWorker client exposes. Declared
21
+ * structurally rather than as `WorkerClient<any, any>` — instantiating
22
+ * WorkerClient's generics with `any` collapses its method map to an index
23
+ * signature no concrete client satisfies.
24
+ */
25
+ type AnyWorkerClient = {
26
+ start(): void;
27
+ terminate(): void;
28
+ readonly pool: WorkerPool<any, any> | null;
29
+ readonly sharedMemory: unknown;
30
+ };
31
+
32
+ /**
33
+ * One pool's shape minus its registry name — also the return type of the
34
+ * async pool factories AtollModule.forRootAsync/registerPoolAsync accept.
35
+ * Three forms:
36
+ *
37
+ * // inline: the worker factory keeps the bundler-detectable literal so the
38
+ * // TS source is the reference
39
+ * { worker: () => new Worker(new URL('./x.worker.ts', import.meta.url)), sharedMemory, tasks }
40
+ *
41
+ * // existing: a domain package's already-configured pool factory — the
42
+ * // same instance task helpers resolve, registered for DI + lifecycle
43
+ * { pool: getIncidentsPool }
44
+ *
45
+ * // typed client: a domain package's connectWorker client — registered
46
+ * // for DI + lifecycle (terminate() on destroy; it re-spawns lazily)
47
+ * { client: incidents }
48
+ */
49
+ export type AtollPoolSpec<
50
+ S extends SharedSpec = SharedSpec,
51
+ T extends TaskMap = TaskMap,
52
+ > =
53
+ | (Omit<WorkerPoolConfig<S, T>, 'workerUrl' | 'createWorker' | 'sharedMemory'> & {
54
+ /** Spawns one pool worker — usually `() => new Worker(new URL('./x.worker.ts', import.meta.url))`. */
55
+ worker: () => Worker;
56
+ /**
57
+ * The contract this pool shares with its workers. Typed as SharedMemory<S>
58
+ * (not the config field's intersection) so declarations for heterogeneous
59
+ * specs coexist — access fields through the contract const, not the pool.
60
+ * Omit for message-only pools.
61
+ */
62
+ sharedMemory?: SharedMemory<S>;
63
+ })
64
+ | {
65
+ /**
66
+ * An already-configured pool — e.g. a domain package's singleton the
67
+ * task helpers dispatch through. Registered for DI + lifecycle; the
68
+ * same instance is what injectAtollPool returns.
69
+ */
70
+ // WorkerPool<S,T> is invariant on S through SharedAccess, so the
71
+ // existing-pool form accepts any specialization.
72
+ pool: () => WorkerPool<any, any>;
73
+ }
74
+ | {
75
+ /**
76
+ * An already-built connectWorker client — the provider value is the
77
+ * client itself, so injectAtollPool returns the typed call surface.
78
+ * Terminated on injector destroy; the lazy client re-spawns on the
79
+ * next call.
80
+ */
81
+ client: AnyWorkerClient;
82
+ };
83
+
84
+ /**
85
+ * One pool's declaration inside provideAtoll — a named AtollPoolSpec. The name
86
+ * selects the injection token (`ATOLL_POOL:<name>`); the first pool defaults
87
+ * to "default".
88
+ */
89
+ export type AtollPoolDeclaration<
90
+ S extends SharedSpec = SharedSpec,
91
+ T extends TaskMap = TaskMap,
92
+ > = { name?: string } & AtollPoolSpec<S, T>;
93
+
94
+ /** provideAtoll / AtollModule.forRoot options. */
95
+ export interface AtollProvideOptions {
96
+ pools?: AtollPoolDeclaration[];
97
+ }
98
+
99
+ /**
100
+ * Composable feature for provideAtoll — the withX pattern. Reserved for
101
+ * cross-cutting additions (logging, timeouts); carries extra providers.
102
+ */
103
+ export interface AtollFeature {
104
+ kind: string;
105
+ providers: Provider[];
106
+ }
107
+
108
+ const poolTokens = new Map<string, InjectionToken<WorkerPool>>();
109
+
110
+ /** InjectionToken for the pool registered under `name` — ATOLL_POOL:<name>. */
111
+ export function getAtollPoolToken(name = 'default'): InjectionToken<WorkerPool> {
112
+ let token = poolTokens.get(name);
113
+ if (!token) poolTokens.set(name, (token = new InjectionToken<WorkerPool>(`ATOLL_POOL:${name}`)));
114
+ return token;
115
+ }
116
+
117
+ /**
118
+ * Injects the WorkerPool registered under `name` by provideAtoll — call in an
119
+ * injection context (field initializer / constructor / factory). Pass the
120
+ * domain pool type to keep its task methods typed; override the provider in
121
+ * TestBed to stub the pool.
122
+ */
123
+ export function injectAtollPool<T = WorkerPool>(name = 'default'): T {
124
+ return inject(getAtollPoolToken(name)) as unknown as T;
125
+ }
126
+
127
+ type NamedPoolDeclaration = AtollPoolDeclaration & { name: string };
128
+
129
+ const defaultName = (cfg: AtollPoolDeclaration, i: number) =>
130
+ cfg.name ?? (i === 0 ? 'default' : `pool${i}`);
131
+
132
+ /** @internal Instantiate whatever a declaration describes — client / existing pool / inline config. */
133
+ export function buildAtollPool(cfg: NamedPoolDeclaration): WorkerPool {
134
+ if ('client' in cfg) return cfg.client as unknown as WorkerPool;
135
+ if ('pool' in cfg) return cfg.pool() as unknown as WorkerPool;
136
+ const { name: _name, worker, sharedMemory, ...rest } = cfg;
137
+ return new WorkerPool({
138
+ ...rest,
139
+ ...(sharedMemory
140
+ ? { sharedMemory: sharedMemory as SharedMemory<SharedSpec> & SharedAccess<SharedSpec> }
141
+ : {}),
142
+ createWorker: worker,
143
+ });
144
+ }
145
+
146
+ /**
147
+ * @internal The provider list both provideAtoll and AtollModule's statics are
148
+ * built from — keeps the declaration-union handling in one place. Appends an
149
+ * ENVIRONMENT_INITIALIZER that eagerly spawns each pool and terminates it
150
+ * when the owning injector is destroyed (app teardown / HMR).
151
+ */
152
+ export function atollProviders(
153
+ pools: AtollPoolDeclaration[],
154
+ features: AtollFeature[] = [],
155
+ ): Provider[] {
156
+ const named = pools.map((cfg, i) => ({ ...cfg, name: defaultName(cfg, i) }));
157
+ return [
158
+ ...named.map(
159
+ (cfg): Provider => ({
160
+ provide: getAtollPoolToken(cfg.name),
161
+ useFactory: () => buildAtollPool(cfg),
162
+ }),
163
+ ),
164
+ {
165
+ provide: ENVIRONMENT_INITIALIZER,
166
+ multi: true,
167
+ useValue: () => {
168
+ const destroyRef = inject(DestroyRef);
169
+ for (const cfg of named) {
170
+ const pool = inject(getAtollPoolToken(cfg.name)); // force eager spawn
171
+ destroyRef.onDestroy(() => pool.terminate());
172
+ }
173
+ },
174
+ },
175
+ ...features.flatMap((f) => f.providers),
176
+ ];
177
+ }
178
+
179
+ /**
180
+ * Registers atoll worker pools as Angular providers — the provideHttpClient
181
+ * analog:
182
+ *
183
+ * bootstrapApplication(AppComponent, {
184
+ * providers: [
185
+ * provideAtoll({ pools: [{ name: 'incidents', client: incidents }] }),
186
+ * ],
187
+ * });
188
+ *
189
+ * // anywhere DI runs:
190
+ * private readonly incidents = injectAtollPool<IncidentsClient>('incidents');
191
+ *
192
+ * Pools spawn eagerly and terminate when the environment injector is
193
+ * destroyed (app teardown / HMR). Also usable at route level via
194
+ * `Route.providers` for lazily-scoped pools.
195
+ */
196
+ export function provideAtoll(
197
+ options: AtollProvideOptions,
198
+ ...features: AtollFeature[]
199
+ ): EnvironmentProviders {
200
+ return makeEnvironmentProviders(atollProviders(options.pools ?? [], features));
201
+ }