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 ContractTests/Client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.core-client-contract",
"version": "0.51.0",
"version": "0.52.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion Documentation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati
Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source.

:::caution[Source preview, no full parity]
No package is published to npm; the manifests are at version 0.51.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence.
No package is published to npm; the manifests are at version 0.52.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence.
:::

## What it looks like
Expand Down
44 changes: 44 additions & 0 deletions Documentation/proxy-generation/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,56 @@ Match these to the server's [endpoint mapping](../core/endpoint-mapping.md), or
| `--metadata <file>` | Off | Generate server artifact metadata at the given absolute path and infer undecorated bindings |
| `--use-generated-metadata` | Off | Infer the same bindings for client-only generation without publishing a metadata module |
| `--check-metadata` | Off | Read-only check that a module passed with `--metadata` matches current source |
| `--type-mapping <Type>=<package>[#<export>]` | None | Import a type from another package instead of generating it; repeat for several types. See [Map types to another package](#map-types-to-another-package) |
| `--watch` | Off | Debounce edits under the artifacts root or in referenced local source files and regenerate; ignores co-located `*.proxy.ts` writes, a dedicated nested output folder, and the generated metadata module. Backend edits inside a co-located nested output folder trigger regeneration. Stdout reports `Watch ready` after the initial generation and watcher registration, then `Watch change detected` when an edit schedules regeneration; referenced external files are also polled to recover missed directory notifications |

Output is co-located when it equals or contains the artifacts root, or when a nested output folder contains a discovered backend contributor: a command, read model, validator, resolved concept/model/enum, or class emitted in generated metadata (for example, `--artifacts src --output src/Features` with a validator in `Features`). An unrelated helper alone does not make a nested output co-located. Co-located output requires `--use-proxy-file-suffix` (generation fails without it) and writes no generated barrels. A handwritten `*.proxy.ts` collision fails before any files are published. Stale deletion affects only generator-owned files, not backend modules or components. Runtime discovery ignores the proxy suffix and `.tsx` components.

A genuinely dedicated output folder nested inside the artifacts root (for example, `--artifacts src --output src/generated` with no analyzed backend contributors in `generated`) retains separate-output behavior: barrels are written by default, the suffix is optional, and generated files are excluded from artifact analysis and watch regeneration. If the server also discovers the artifacts root at runtime, place dedicated output outside that root or use `--use-proxy-file-suffix` so runtime discovery does not import generated proxy modules.

## Map types to another package

When several packages share a model, generate it once and import it everywhere else. A mapped type is not written to the proxy tree; every command, query, and model that references it imports it from the package instead. Route names, output layout, and every other default stay the same.

```bash
arc-proxygenerator --project tsconfig.json --artifacts src --output src \
--type-mapping Shared.Money=@acme/money \
--type-mapping Shared.Amount=@acme/money#Money
```

The generated proxies then contain:

```typescript
import { Money } from '@acme/money';
```

The value is `<Type>=<package>[#<export>]`. Programmatic callers pass the same settings as `typeMappings` to `generateFromSource` and `analyzeSource`:

```typescript
await generateFromSource({
project, artifacts, output,
typeMappings: {
'Shared.Money': { package: '@acme/money' },
'Shared.Amount': { package: '@acme/money', export: 'Money' }
}
});
```

| Part | Meaning |
| --- | --- |
| Type | The type's namespace-qualified name: the [root namespace](#route-alignment), then the folders between the artifacts root and its file, then the class or enum name. `Shared/Money.ts` under the artifacts root is `Shared.Money`. Segments to skip do not change it. The type is matched by its resolved declaration, not by how it is spelled at the use site, so an aliased import maps the same way |
| `package` | The npm package, optionally with a subpath, such as `@acme/money` or `@acme/money/models` |
| `export` | The symbol the package exports. Defaults to the type's own name; set it when the package uses a different name |

Behavior and limits:

- A mapped class or enum works as a field, an array element, a command or query result, or a base class. A mapped enum keeps its primitive runtime constructor.
- The package must export a class the generated code can use as a runtime constructor, decorated the way generated models are. It is imported as a value, including with `--emit-interfaces`. Install the package separately; the generator does not build or publish it.
- Only types below the artifacts root can be matched. The fields of a mapped type are not analyzed.
- Each type can be mapped once. A repeated `--type-mapping` for one type fails, and so does an invalid type name, package name, or export name.
- Generation fails, naming the file, when a mapped export would share a name with another import or declaration in the same generated file, for example a mapped `Command` next to the `Command` imported from `@cratis/arc/commands`. Export the type from your package under a different name.
- A mapping that matches no type reachable from the artifacts root is reported as a warning, since it is usually a misspelled name.

## Programmatic use

The package also exports `analyzeSource`, `renderSource`, `renderGeneratedMetadata`, and `generateFromSource` for the same pipeline, with the `SourceGeneratorOptions` and `SourceRenderOptions` types. Programmatic `generateFromSource` accepts `metadata` or `generatedMetadata: true` for inference. `renderSource` accepts a `recordedRules` override. The low-level manifest path exports `renderClientManifest` and `generateClient`; see [Low-level manifest](low-level-manifest.md).
Expand Down
2 changes: 1 addition & 1 deletion Documentation/reference/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Packages
description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client.
---

Every package in this repository is at version 0.51.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways:
Every package in this repository is at version 0.52.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways:

- **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository's Yarn workspace.
- **In your own project.** Pack each package you need with `yarn workspace <package> pack --out <file>` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class TaskItem {
| `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.19.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. |
| `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. |

Every package manifest is at version 0.51.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended.
Every package manifest is at version 0.52.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended.

## Try it

Expand Down
6 changes: 3 additions & 3 deletions Source/Chronicle/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.chronicle",
"version": "0.51.0",
"version": "0.52.0",
"publishConfig": {
"access": "public"
},
Expand Down Expand Up @@ -34,8 +34,8 @@
"README.md"
],
"peerDependencies": {
"@cratis/arc.core": "^0.51.0",
"@cratis/arc.testing": "^0.51.0",
"@cratis/arc.core": "^0.52.0",
"@cratis/arc.testing": "^0.52.0",
"@cratis/chronicle": "^6.17.0",
"@cratis/fundamentals": "^7.19.6",
"rxjs": "^7.8.2",
Expand Down
2 changes: 1 addition & 1 deletion Source/CodeAnalysis/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/eslint-plugin-arc-core",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"description": "ESLint diagnostics for Arc for TypeScript server artifacts",
Expand Down
2 changes: 1 addition & 1 deletion Source/Core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.core",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"publishConfig": {
Expand Down
2 changes: 1 addition & 1 deletion Source/Cratis/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/cratis",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"description": "Arc and experimental Chronicle composition for Node.js",
Expand Down
4 changes: 2 additions & 2 deletions Source/Drizzle/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.drizzle",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"publishConfig": {
Expand Down Expand Up @@ -29,7 +29,7 @@
"README.md"
],
"peerDependencies": {
"@cratis/arc.core": "^0.51.0",
"@cratis/arc.core": "^0.52.0",
"@cratis/fundamentals": "^7.19.6",
"drizzle-orm": "^0.45.0",
"rxjs": "^7.8.2"
Expand Down
2 changes: 1 addition & 1 deletion Source/Express/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.express",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"publishConfig": {
Expand Down
2 changes: 1 addition & 1 deletion Source/Fastify/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.fastify",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"publishConfig": {
Expand Down
2 changes: 1 addition & 1 deletion Source/Hono/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.hono",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"publishConfig": {
Expand Down
4 changes: 2 additions & 2 deletions Source/MongoDB/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.mongodb",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"publishConfig": {
Expand Down Expand Up @@ -29,7 +29,7 @@
"README.md"
],
"peerDependencies": {
"@cratis/arc.core": "^0.51.0",
"@cratis/arc.core": "^0.52.0",
"@cratis/fundamentals": "^7.19.6",
"@opentelemetry/api": "^1.9.0",
"mongodb": "^6.21.0",
Expand Down
2 changes: 1 addition & 1 deletion Source/Testing/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cratis/arc.testing",
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"license": "MIT",
"publishConfig": {
Expand Down
2 changes: 2 additions & 0 deletions Source/Tools/ProxyGenerator/SourceModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ export interface SourceModel {
readonly members?: readonly { name: string; value: string | number }[];
readonly base?: string;
readonly baseKey?: string;
/** Package a mapped base class is imported from; the base is not generated. */
readonly basePackage?: string;
readonly derivedTypeId?: string;
}
2 changes: 2 additions & 0 deletions Source/Tools/ProxyGenerator/SourceType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface SourceType {
/** Local import name when a model name collides in the generated file. */
readonly alias?: string;
readonly package?: string;
/** Set when the type is imported from a user-configured package mapping rather than generated. */
readonly mapped?: boolean;
readonly enumerable: boolean;
readonly nullable: boolean;
readonly void: boolean;
Expand Down
23 changes: 21 additions & 2 deletions Source/Tools/ProxyGenerator/SourceTypeResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, relative, sep } from 'node:path';
import type { SourceField } from './SourceField.js';
import type { SourceModel } from './SourceModel.js';
import type { SourceType } from './SourceType.js';
import { resolveTypeMappings, type ResolvedTypeMapping, type TypeMappings } from './typeMappings.js';
import { fieldName, isPackageSymbol, isStandardType, isTypeFrom, originalSymbol } from './sourceSymbols.js';

const fundamentals = new Set(['Guid', 'DateOnly', 'TimeOnly', 'TimeSpan']);
Expand All @@ -14,7 +15,20 @@ export class SourceTypeResolver {
private readonly declarations = new Map<string, ts.Declaration>();
constructor(private readonly checker: ts.TypeChecker, private readonly artifacts: string,
private readonly generatedMetadata = false, private readonly rootNamespace = '',
private readonly contribute: (declaration: ts.Declaration) => void = () => {}) {}
private readonly contribute: (declaration: ts.Declaration) => void = () => {}, typeMappings?: TypeMappings) {
this.mappings = resolveTypeMappings(typeMappings);
}
private readonly mappings: ReadonlyMap<string, ResolvedTypeMapping>;
private readonly usedMappings = new Set<string>();
/** Mappings that never matched a reachable type: almost always a misspelled key. */
unusedMappings(): string[] { return [...this.mappings.keys()].filter(key => !this.usedMappings.has(key)); }
/** An imported class is its own runtime constructor; an imported enum keeps the primitive constructor it would have had. */
private mapped(key: string, enumConstructor?: 'String' | 'Number'): SourceType | undefined {
const mapping = this.mappings.get(key);
if (!mapping) return undefined;
this.usedMappings.add(key);
return { ...primitive(mapping.export, enumConstructor ?? mapping.export), package: mapping.package, mapped: true };
}
private namespace(declaration: ts.Declaration): string {
const segments = relative(this.artifacts, dirname(declaration.getSourceFile().fileName)).split(sep).filter(Boolean);
if (segments.includes('..')) throw new Error(`${declaration.getSourceFile().fileName}: reachable model is outside the artifacts root`);
Expand Down Expand Up @@ -73,6 +87,8 @@ export class SourceTypeResolver {
if (declaration && name && !declaration.getSourceFile().isDeclarationFile) {
this.contribute(declaration);
const key = [this.namespace(declaration), name].filter(Boolean).join('.');
const mapped = this.mapped(key);
if (mapped) return { ...mapped, nullable };
this.checkIdentity(key, declaration);
if (!this.models.has(key)) {
this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields: [] });
Expand Down Expand Up @@ -106,14 +122,15 @@ export class SourceTypeResolver {
const baseType = type.getBaseTypes()?.find(base => base.symbol?.declarations?.some(ts.isClassDeclaration) &&
!base.symbol.declarations.every(origin => origin.getSourceFile().isDeclarationFile));
const base = baseType ? this.resolve(baseType, declaration) : undefined;
const basePackage = base?.mapped ? base.package : undefined;
const derived = (ts.getDecorators(declaration) ?? []).map(decorator => decorator.expression).find(expression => {
if (!ts.isCallExpression(expression)) return false;
const symbol = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name : expression.expression;
return isPackageSymbol(this.checker, symbol, 'derivedType', '@cratis/fundamentals');
});
const derivedTypeId = derived && ts.isCallExpression(derived) && derived.arguments[0] && ts.isStringLiteral(derived.arguments[0]) ? derived.arguments[0].text : undefined;
this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields,
base: base?.model, baseKey: base?.modelKey, derivedTypeId });
base: basePackage ? base!.text : base?.model, baseKey: base?.modelKey, basePackage, derivedTypeId });
}
return { ...primitive(name, name), model: name, modelKey: key, nullable };
}
Expand All @@ -127,6 +144,8 @@ export class SourceTypeResolver {
const members = declaration.members.map(member => ({ name: fieldName(member.name), value: this.checker.getConstantValue(member) }));
if (members.some(member => typeof member.value !== 'number' && typeof member.value !== 'string')) return this.unsupported(type, location);
const key = [this.namespace(declaration), name].filter(Boolean).join('.');
const mapped = this.mapped(key, typeof members[0]?.value === 'string' ? 'String' : 'Number');
if (mapped) return { ...mapped, nullable };
this.checkIdentity(key, declaration);
this.models.set(key, {
kind: 'enum', name, namespace: this.namespace(declaration), fields: [], members: members as { name: string; value: number | string }[]
Expand Down
Loading
Loading