Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/site/capabilities/background-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ The runtime scans <code>workers/jobs</code> (the primary user jobs directory) fo
default-exported handlers, and also includes plugin-contributed handlers from
<code>plugins/workers/jobs</code> (and <code>plugins/triggers/jobs</code> when present). The
generated registry lands at
<code>.netscript/generated/plugin-workers/jobs.registry.ts</code>, keyed by each handler's
<code>.netscript/generated/plugin-workers/job-registry.ts</code>, keyed by each handler's
<code>id</code>. Background execution runs from <code>plugins/workers/bin/combined.ts</code>,
a <em>separate</em> process from the <code>:8091</code> API service — the API enqueues, the
runner executes. Set <code>WORKERS_CONCURRENCY</code> on the worker background process when you
Expand Down
2 changes: 1 addition & 1 deletion docs/site/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ These are the words you will meet first — in the [Quickstart](/quickstart/), t
] }) }}

{{ comp.apiTable({ caption: "R–S", rows: [
{ name: "registry", type: "generated registry file", desc: "A generated index the runtime reads to discover what your app contains without runtime reflection. <code>netscript plugin list</code> emits the plugin registry, and the workers profile generates a jobs registry (for example to <code>.netscript/generated/plugin-workers/jobs.registry.ts</code>, keyed by job id). Registries are derived artifacts — regenerate them, do not hand-edit. See <a href=\"/explanation/plugin-system/\">Explanation → Plugin system</a>." },
{ name: "registry", type: "generated registry file", desc: "A generated index the runtime reads to discover what your app contains without runtime reflection. <code>netscript plugin list</code> emits the plugin registry, and the workers profile generates a jobs registry (for example to <code>.netscript/generated/plugin-workers/job-registry.ts</code>, keyed by job id). Registries are derived artifacts — regenerate them, do not hand-edit. See <a href=\"/explanation/plugin-system/\">Explanation → Plugin system</a>." },
{ name: "saga", type: "defineSaga(id)…build()", desc: "A durable, message-driven workflow authored with the fluent builder from <code>@netscript/plugin-sagas-core</code>: <code>defineSaga(id).durability('t1').state&lt;S&gt;({...}).on&lt;Type,Payload&gt;(type, handler).build()</code>. Each <code>.on(...)</code> handler reacts to a message and returns effects such as <code>sagaComplete({...})</code>. Runtime state persists to a chosen <strong>saga store backend</strong> (kv | prisma). The sagas service lists registered sagas at <code>/api/v1/sagas/sagas</code> on port 8092. See <a href=\"/tutorials/storefront/04-checkout-saga/\">Tutorial → Durable workflow</a> and <a href=\"/capabilities/durable-sagas/\">Capabilities → Durable sagas</a>." },
{ name: "saga store backend", type: "NETSCRIPT_SAGA_STORE=kv|prisma", desc: "Where a durable saga runtime persists its checkpointed state — selectable as <code>'kv'</code> or <code>'prisma'</code> via <code>NETSCRIPT_SAGA_STORE</code> (or appsettings <code>sagas.store.backend</code>) and constructed with <code>createDurableSagaRuntime({ backend, prisma })</code>. The selection is <em>mandatory</em> — the runtime throws if it is unset, and Prisma mode requires a client. This is distinct from a saga's <strong>durability tier</strong>. See <a href=\"/capabilities/durable-sagas/\">Capabilities → Durable sagas</a> and <a href=\"/explanation/durability-model/\">Explanation → Durability model</a>." },
{ name: "service", type: "defineService(router, {...})", desc: "An oRPC HTTP application. Local app services use a one-shot <code>defineService(router, { name, version, port, openapi })</code> call (the <code>users</code> service on port 3001), while plugin API services use the fluent <code>createService(router, {...}).withCors()…serve()</code> builder — two construction APIs in the same project. oRPC is served at <code>/api/rpc/*</code>; the service layer also offers an authn/authz middleware seam (<code>.withAuthn()</code> / <code>.withAuthz()</code>). See <a href=\"/capabilities/services/\">Capabilities → Services</a> and <a href=\"/reference/service/\">reference/service/</a>." },
Expand Down
2 changes: 1 addition & 1 deletion docs/site/how-to/author-a-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ canonical location:

Listing the plugin is not enough — the runtime addresses contributions through a **generated
registry**. For a worker-archetype plugin, the generator scans `plugins/<name>/jobs/` and writes a
jobs registry (e.g. `.netscript/generated/plugin-<name>/jobs.registry.ts`) keyed by each handler's
jobs registry (e.g. `.netscript/generated/plugin-<name>/job-registry.ts`) keyed by each handler's
`id`. Generate it:

```sh
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/kernel/assets/embedded.generated.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { DistributedApplicationBuilder } from '{{__slot2__}}';
import { configureDashboard } from '{{__slot3__}}';
import { registerInfrastructure } from '{{__slot4__}}';
import { tryHandleDbCliMode } from '{{__slot5__}}';
import { registerServices } from '{{__slot6__}}';
import { registerServices, wireServiceReferences } from '{{__slot6__}}';
import { registerPlugins } from '{{__slot7__}}';
import { registerBackgroundProcessors } from '{{__slot8__}}';
import { registerApps } from '{{__slot9__}}';
Expand All @@ -20,11 +20,12 @@ import { registerTools } from '{{__slot10__}}';
* 1. Dashboard OTLP endpoint configuration
* 2. Infrastructure (databases + caches)
* 3. DB CLI short-circuit mode
* 4. Services (two-pass with cross-reference wiring)
* 4. Services (creation pass)
* 5. Plugins (two-pass with plugin→plugin + plugin→service refs)
* 6. Background processors (workers, sagas, triggers)
* 7. Applications (web apps, Tauri desktop, task apps)
* 8. Development tools (Prisma Studio, etc.)
* 6. Service reference wiring (service→service + service→plugin refs)
* 7. Background processors (workers, sagas, triggers)
* 8. Applications (web apps, Tauri desktop, task apps)
* 9. Development tools (Prisma Studio, etc.)
*
* @param builder - The Aspire distributed application builder
* @param configPath - Path to appsettings.json
Expand All @@ -51,18 +52,21 @@ export async function createNetScriptAppHost(
return;
}

// 4. Register services (two-pass: create all, then wire cross-refs)
// 4. Register services
const services = await registerServices(builder, config, infrastructure, appHostDir);

// 5. Register plugins (two-pass: create all, then wire plugin→plugin refs)
const plugins = await registerPlugins(builder, config, infrastructure, services, appHostDir);

// 6. Register background processors
// 6. Wire service references after plugin APIs exist
await wireServiceReferences(config, services, plugins);

// 7. Register background processors
await registerBackgroundProcessors(builder, config, infrastructure, services, plugins, appHostDir);

// 7. Register applications
// 8. Register applications
await registerApps(builder, config, infrastructure, services, plugins, appHostDir);

// 8. Register development tools
// 9. Register development tools
await registerTools(builder, config, infrastructure, appHostDir);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { InfrastructureContext } from './register-infrastructure.mjs';
import {
buildDatabaseUriEnvKey,
buildOtelEnvVars,
extractPluginReferences,
extractServiceReferences,
resolvePermissions,
resolveWorkspacePath,
} from '{{__slot3__}}';
Expand All @@ -18,10 +20,9 @@ type ExecutableResource = ReturnType<DistributedApplicationBuilder['addExecutabl
* - Pass 1: Create all service resources via `addExecutable()` with resolved
* permissions, working directory, HTTP endpoint, OTEL telemetry (3 vars
* via `denoApp` mode), and infrastructure dependencies.
* - Pass 2: Wire `ServiceReferences` via `getEndpoint('http')` +
* `withEnvironment()` using the Aspire service-discovery env var
* convention (`services__{name}__http__0`) to avoid forward-reference
* issues where service A references service B but B hasn't been created yet.
* - Reference wiring is completed by `wireServiceReferences(...)` after
* services and plugins have both been created, so service -> plugin API
* discovery uses the same endpoint environment convention as other resources.
*
* @param builder - The Aspire distributed application builder
* @param config - Parsed NetScript configuration
Expand All @@ -41,8 +42,42 @@ export async function registerServices(
// --- Pass 1: Create all service resources ---
{{__slot4__}}

// --- Pass 2: Wire cross-references ---
{{__slot5__}}

return services;
}

/**
* Wires endpoint environment variables for service references.
*
* Services can reference plain services through `ServiceReferences` and plugin
* API resources through `PluginReferences`. Plugin resources are created after
* services, so this pass runs from the AppHost orchestrator after
* `registerPlugins(...)` returns.
*
* @param config - Parsed NetScript configuration
* @param services - Registered service resources
* @param plugins - Registered plugin API resources
*/
export async function wireServiceReferences(
config: NetScriptConfig,
services: Map<string, ExecutableResource>,
plugins: Map<string, ExecutableResource>,
): Promise<void> {
for (const [name, entry] of Object.entries(config.Services)) {
const resource = services.get(name);
if (!resource) continue;

for (const ref of extractServiceReferences(entry)) {
const endpoint = await services.get(ref)?.getEndpoint('http');
if (endpoint) {
await resource.withEnvironment(`services__${ref}__http__0`, endpoint);
}
}

for (const ref of extractPluginReferences(entry)) {
const endpoint = await plugins.get(ref)?.getEndpoint('http');
if (endpoint) {
await resource.withEnvironment(`services__${ref}__http__0`, endpoint);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ for (const file of files) {
entries.push(\` [\${name}.id, \${name}],\`);
}
await Deno.mkdir(join(projectRoot, '.netscript', 'generated', 'plugin-workers'), { recursive: true });
await Deno.writeTextFile(join(projectRoot, '.netscript', 'generated', 'plugin-workers', 'jobs.registry.ts'), [
await Deno.writeTextFile(join(projectRoot, '.netscript', 'generated', 'plugin-workers', 'job-registry.ts'), [
"import type { JobHandler, RegisterJobInput } from '@netscript/plugin-workers-core/runtime';",
...imports,
'',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
* 1. Dashboard OTLP endpoint configuration
* 2. Infrastructure (databases + caches)
* 3. DB CLI short-circuit mode
* 4. Services (two-pass with cross-reference wiring)
* 4. Services (creation pass)
* 5. Plugins (two-pass with plugin→plugin + plugin→service refs)
* 6. Background processors (workers, sagas, triggers)
* 7. Applications (web apps, Tauri desktop, task apps)
* 8. Development tools (Prisma Studio, etc.)
* 6. Service reference wiring (service→service + service→plugin refs)
* 7. Background processors (workers, sagas, triggers)
* 8. Applications (web apps, Tauri desktop, task apps)
* 9. Development tools (Prisma Studio, etc.)
*
* Empty sections produce no-op functions in their respective modules —
* the index file always includes all registration phases.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@
* - **Pass 1:** Create ALL service resources — `addExecutable()` + working dir +
* HTTP endpoint + executable-mode OTEL telemetry +
* infrastructure dependencies (`waitFor` primary database).
* - **Pass 2:** Wire `ServiceReferences` via `getEndpoint('http')` +
* `withEnvironment()` — all services now exist in the Map, so forward
* references resolve cleanly. Uses Aspire service-discovery env var
* convention: `services__{name}__http__0`.
* - **Pass 2:** Wire `ServiceReferences` and `PluginReferences` via
* `getEndpoint('http')` + `withEnvironment()` after services and plugins
* both exist. Uses Aspire service-discovery env var convention:
* `services__{name}__http__0`.
*
* Services use `--watch-hmr` for watch mode (HMR-capable), unlike background
* processors and apps which use `--watch`.
*/

import type { RegisterServicesOptions } from '../types.ts';
import { fileHeader, safeIdentifier } from '../_utils.ts';
import { fileHeader } from '../_utils.ts';
import { SCAFFOLD_ASPIRE_MODULES } from '../../../../constants/scaffold/scaffold-aspire.ts';
import { SCAFFOLD_DIRS } from '../../../../constants/scaffold/scaffold-dirs.ts';
import { RESOURCE_DEFAULTS } from '@netscript/aspire/constants';
Expand Down Expand Up @@ -104,52 +104,16 @@ export function generateRegisterServices(options: RegisterServicesOptions): stri
pass1Blocks.push(lines.join('\n'));
}

// --- Pass 2 blocks: wire cross-references ---
const pass2Blocks: string[] = [];

for (const [name, entry] of entries) {
const refs = entry.ServiceReferences;
if (!refs || refs.length === 0) continue;

const lines: string[] = [];
lines.push(` // --- ${name}: wire ServiceReferences via endpoint env vars ---`);
lines.push(` {`);
lines.push(` const resource = services.get('${name}');`);
lines.push(` if (resource) {`);

for (const ref of refs) {
const refId = safeIdentifier(ref);
lines.push(
` const ${refId}Endpoint = await services.get('${ref}')?.getEndpoint('http');`,
);
lines.push(` if (${refId}Endpoint) {`);
lines.push(
` await resource.withEnvironment('services__${ref}__http__0', ${refId}Endpoint);`,
);
lines.push(` }`);
}

lines.push(` }`);
lines.push(` }`);

pass2Blocks.push(lines.join('\n'));
}

// --- Compose full output ---
const pass1Content = pass1Blocks.length > 0
? pass1Blocks.join('\n\n')
: ' // No services configured';

const pass2Content = pass2Blocks.length > 0
? pass2Blocks.join('\n\n')
: ' // No cross-references to wire';

return renderTemplateAssetSync(TEMPLATE_KEYS.generatedAspireHelpersGenerateRegisterServices1, {
__slot0__: String(fileHeader('register-services.mts')),
__slot1__: String(SCAFFOLD_ASPIRE_MODULES.SDK_IMPORT_FROM_HELPERS),
__slot2__: String(SCAFFOLD_ASPIRE_MODULES.ASPIRE_COMPAT_IMPORT),
__slot3__: String(SCAFFOLD_ASPIRE_MODULES.ASPIRE_COMPAT_IMPORT),
__slot4__: String(pass1Content),
__slot5__: String(pass2Content),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ describe('generateRegisterServices', () => {
assertStringIncludes(output, 'export async function registerServices(');
});

it('should import buildOtelEnvVars, resolvePermissions, resolveWorkspacePath', () => {
it('should import register-services dependencies', () => {
const output = generateRegisterServices(emptyOptions);
assertStringIncludes(output, 'buildOtelEnvVars,');
assertStringIncludes(output, 'extractPluginReferences,');
assertStringIncludes(output, 'extractServiceReferences,');
assertStringIncludes(output, 'resolvePermissions,');
assertStringIncludes(output, 'resolveWorkspacePath,');
assertStringIncludes(output, "from './_aspire-compat.mjs'");
Expand All @@ -56,7 +58,7 @@ describe('generateRegisterServices', () => {
services: { users: fixtures.MINIMAL_SERVICE },
});
assertStringIncludes(output, '// --- Pass 1: Create all service resources ---');
assertStringIncludes(output, '// --- Pass 2: Wire cross-references ---');
assertStringIncludes(output, 'export async function wireServiceReferences(');
});

it('should register services via addExecutable with correct port and entrypoint', () => {
Expand Down Expand Up @@ -109,35 +111,35 @@ describe('generateRegisterServices', () => {
assertStringIncludes(output, '.waitFor(infrastructure.primaryDatabase)');
});

it('should wire cross-references in pass 2', () => {
it('should wire service references from the services map', () => {
const output = generateRegisterServices({
...emptyOptions,
services: {
users: fixtures.MINIMAL_SERVICE,
orders: fixtures.SERVICE_WITH_REFS,
},
});
assertStringIncludes(
output,
'// --- orders: wire ServiceReferences via endpoint env vars ---',
);
assertStringIncludes(output, "services.get('users')?.getEndpoint('http')");
assertStringIncludes(output, "resource.withEnvironment('services__users__http__0'");
assertStringIncludes(output, 'for (const ref of extractServiceReferences(entry))');
assertStringIncludes(output, "const endpoint = await services.get(ref)?.getEndpoint('http')");
assertStringIncludes(output, 'services__${ref}__http__0');
});

it('should not emit pass 2 blocks for services without references', () => {
it('should wire plugin references from the plugins map', () => {
const output = generateRegisterServices({
...emptyOptions,
services: { users: fixtures.MINIMAL_SERVICE },
services: {
reporting: fixtures.SERVICE_WITH_PLUGIN_REFS,
},
});
// Pass 2 should have no-op comment since users has no ServiceReferences
assertStringIncludes(output, '// No cross-references to wire');
assertStringIncludes(output, 'for (const ref of extractPluginReferences(entry))');
assertStringIncludes(output, "const endpoint = await plugins.get(ref)?.getEndpoint('http')");
assertStringIncludes(output, 'services__${ref}__http__0');
});

it('should handle empty services', () => {
const output = generateRegisterServices(emptyOptions);
assertStringIncludes(output, '// No services configured');
assertStringIncludes(output, '// No cross-references to wire');
assertStringIncludes(output, 'export async function wireServiceReferences(');
});
});
// generateRegisterPlugins
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export const SERVICE_WITH_REFS: ServiceEntry = {
ServiceReferences: ['users'],
};

export const SERVICE_WITH_PLUGIN_REFS: ServiceEntry = {
Enabled: true,
Runtime: 'deno',
Port: 3002,
Entrypoint: 'src/main.ts',
Workdir: 'services/reporting',
PluginReferences: ['workers-api'],
};

// --- Plugin Fixtures ---

export const MINIMAL_PLUGIN: PluginEntry = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ describe('generateIndex', () => {
);
assertStringIncludes(
output,
"import { registerServices } from './register-services.mjs'",
"import { registerServices, wireServiceReferences } from './register-services.mjs'",
);
assertStringIncludes(
output,
Expand All @@ -281,6 +281,7 @@ describe('generateIndex', () => {
const infraIdx = output.indexOf('registerInfrastructure(builder');
const servicesIdx = output.indexOf('registerServices(builder');
const pluginsIdx = output.indexOf('registerPlugins(builder');
const wireServicesIdx = output.indexOf('wireServiceReferences(config, services, plugins)');
const backgroundIdx = output.indexOf('registerBackgroundProcessors(builder');
const appsIdx = output.indexOf('registerApps(builder');
const toolsIdx = output.indexOf('registerTools(builder, config, infrastructure, appHostDir)');
Expand All @@ -289,7 +290,8 @@ describe('generateIndex', () => {
assert(infraIdx > dashboardIdx, 'infrastructure should follow dashboard');
assert(servicesIdx > infraIdx, 'services should follow infrastructure');
assert(pluginsIdx > servicesIdx, 'plugins should follow services');
assert(backgroundIdx > pluginsIdx, 'background should follow plugins');
assert(wireServicesIdx > pluginsIdx, 'service reference wiring should follow plugins');
assert(backgroundIdx > wireServicesIdx, 'background should follow service reference wiring');
assert(appsIdx > backgroundIdx, 'apps should follow background');
assert(toolsIdx > appsIdx, 'tools should follow apps');
});
Expand Down
Loading
Loading