NetScript vs backend frameworks
NetScript carries one contract from request to client, then keeps the job typed
Validation is the route contract, the consumer imports that same contract, and the endpoint hands a validated payload to a named worker. There is no transport DTO to reconcile with a client shape later.
Nest.js: Nest’s controller and dependency-injection model is mature; validation DTOs, Swagger client generation, BullMQ registration, and processor classes are separate surfaces you keep aligned.
Hono: Hono is the smallest credible version of this endpoint and its RPC client is excellent; the background job belongs to the hosting adapter, so portability stops at that boundary.
Encore.dev: Encore makes typed endpoints and provisioned Pub/Sub impressively compact; NetScript keeps the contract object, service client, and worker runtime explicit package surfaces you can compose outside one compiler.
// contracts/reports.ts
import { baseContract } from '@netscript/contracts';
import { z } from 'zod';
const CreateReport = z.object({
accountId: z.string().uuid(),
format: z.enum(['pdf', 'csv']),
});
const Report = z.object({
id: z.string().uuid(),
status: z.literal('queued'),
});
export const ReportsContractV1 = {
create: baseContract
.route({ method: 'POST', path: '/reports' })
.input(CreateReport)
.output(Report),
};
// services/reports/router.ts
import { implement } from '@orpc/server';
import { createServiceClient } from '@netscript/sdk/client';
import { workersContract } from '@netscript/plugin-workers/contracts';
const ReportsV1 = implement(ReportsContractV1);
const workers = createServiceClient<typeof workersContract>({
contract: workersContract,
serviceName: 'workers-api',
routerName: 'workers',
});
export const router = {
create: ReportsV1.create.handler(async ({ input }) => {
const report = await reports.insert(input);
await workers.triggerJob({
id: 'render-report',
payload: { reportId: report.id },
});
return report;
}),
};
// consumers/reports-client.ts
export const reportsClient = createServiceClient<typeof ReportsContractV1>({
contract: ReportsContractV1,
serviceName: 'reports',
});
await reportsClient.create({ accountId, format: 'pdf' });
// plugins/workers/jobs/render-report.ts
import { createSuccessResult, defineJobHandler } from '@netscript/plugin-workers-core';
import { z } from 'zod';
const RenderReport = z.object({ reportId: z.string().uuid() });
type RenderReport = z.infer<typeof RenderReport>;
const renderReport = defineJobHandler<RenderReport>(async (ctx) => {
const payload = RenderReport.parse(ctx.payload);
await renderPdf(payload.reportId);
return createSuccessResult({ reportId: payload.reportId });
});
export default Object.assign(renderReport, { id: 'render-report' as const });
// create-report.dto.ts
export class CreateReportDto {
@IsUUID() accountId!: string;
@IsIn(['pdf', 'csv']) format!: 'pdf' | 'csv';
}
// main.ts
app.useGlobalPipes(new ValidationPipe({ transform: true }));
// reports.controller.ts
@Controller('reports')
export class ReportsController {
constructor(@InjectQueue('reports') private queue: Queue) {}
@Post()
async create(@Body() input: CreateReportDto): Promise<Report> {
const report = await reports.insert(input);
await this.queue.add('render-report', { reportId: report.id });
return report;
}
}
// reports.processor.ts
@Processor('reports')
export class ReportsProcessor extends WorkerHost {
async process(job: Job<{ reportId: string }>) {
if (job.name === 'render-report') await renderPdf(job.data.reportId);
}
}
// consumer.ts — generated from the Swagger document
import { createReport } from './generated-client.ts';
await createReport({ accountId, format: 'pdf' });import { Hono } from 'hono';
import { hc } from 'hono/client';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
type RenderReport = { reportId: string };
type Bindings = { REPORTS: Queue<RenderReport> };
const CreateReport = z.object({
accountId: z.string().uuid(),
format: z.enum(['pdf', 'csv']),
});
const app = new Hono<{ Bindings: Bindings }>()
.post('/reports', zValidator('json', CreateReport), async (c) => {
const report = await reports.insert(c.req.valid('json'));
await c.env.REPORTS.send({ reportId: report.id });
return c.json(report, 202);
});
export type AppType = typeof app;
export const client = hc<AppType>('/');
await client.reports.$post({ json: { accountId, format: 'pdf' } });
export default {
fetch: app.fetch,
async queue(batch: MessageBatch<RenderReport>) {
for (const message of batch.messages) {
await renderPdf(message.body.reportId);
message.ack();
}
},
};import { api } from 'encore.dev/api';
import { Subscription, Topic } from 'encore.dev/pubsub';
interface CreateReport {
accountId: string;
format: 'pdf' | 'csv';
}
interface Report { id: string; status: 'queued' }
interface RenderReport { reportId: string }
const renders = new Topic<RenderReport>('render-reports', {
deliveryGuarantee: 'at-least-once',
});
export const create = api(
{ expose: true, method: 'POST', path: '/reports' },
async (input: CreateReport): Promise<Report> => {
const report = await reports.insert(input);
await renders.publish({ reportId: report.id });
return report;
},
);
const _render = new Subscription(renders, 'render-report', {
handler: async ({ reportId }) => { await renderPdf(reportId); },
});
// A consumer in another Encore service
import { reports } from '~encore/clients';
await reports.create({ accountId, format: 'pdf' });NetScript turns a contract change into a consumer compile error
The consumer imports the contract object; nothing is generated. Add a required field to
CreateReport, and every stale call becomes a TypeScript error at build time.
import { ReportsContractV1 } from '@acme/contracts';
import { createServiceClient } from '@netscript/sdk/client';
const reports = createServiceClient({ contract: ReportsContractV1, serviceName: 'reports' });
await reports.create({ accountId, format: 'pdf' });
NetScript keeps more of a backend change in one typed surface
These are architectural estimates for this generic report endpoint, not benchmark results.
| Architectural estimate | NetScript composition | Convention-split equivalent |
|---|---|---|
| Request-shape sources to keep aligned | 1 contract object | ~2–4 DTO, schema, and client surfaces |
| Consumer client after a contract change | Imported directly | Regenerated or hand-updated |
| Files touched to add one required request field | ~2 | ~3–5 |
| Architectural surface visible at entry | ~87–93% | ~40–53% |