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
60 changes: 60 additions & 0 deletions ContractTests/Client/observable-hub.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { z } from 'zod';
import React from 'react';
import { Arc, ArcContext } from '@cratis/arc.react';
import { ArcServer, AuthenticationStatus, CurrentValueSubject, ObservableEmissionDecision,
defineObservableQuery, serviceToken } from '@cratis/arc.core';
import { Globals } from '@cratis/arc';
Expand Down Expand Up @@ -255,6 +257,64 @@ class Numbers extends ObservableQueryFor {
get requiredRequestParameters() { return []; }
}

for (const kind of ['express', 'fastify', 'hono']) test(`anonymous published React default SSE hub on ${kind}`, async () => {
const subject = new CurrentValueSubject([{ id: '1', name: 'first' }]);
const server = new ArcServer({ observableQueries: [defineObservableQuery({ name: 'Numbers',
schema: z.object({}), observe: () => subject })] });
const listening = await observableHost(kind, server);
const previous = { method: Globals.queryTransportMethod, direct: Globals.queryDirectMode,
mode: Globals.observableQueryTransferMode, factory: Globals.eventSourceFactory,
headers: Globals.httpHeadersCallback, eventSource: globalThis.EventSource, fetch: globalThis.fetch };
let subscription;
let unsubscribeFinished;
try {
const provider = React.createElement(Arc, { origin: listening.origin });
assert.equal(provider.props.queryTransportMethod, undefined);
assert.equal(ArcContext._currentValue.queryTransportMethod, QueryTransportMethod.ServerSentEvents);
resetSharedMultiplexer();
Globals.queryTransportMethod = ArcContext._currentValue.queryTransportMethod;
Globals.queryDirectMode = ArcContext._currentValue.queryDirectMode;
Globals.observableQueryTransferMode = 'full';
Globals.httpHeadersCallback = () => ({});
globalThis.EventSource = FetchEventSource;
Globals.eventSourceFactory = url => new FetchEventSource(url);
unsubscribeFinished = new Promise(resolve => {
globalThis.fetch = (url, options) => {
const response = previous.fetch(url, options);
if (String(url).endsWith('/sse/unsubscribe')) void response.then(resolve, resolve);
return response;
};
});
const query = new Numbers();
query.setOrigin(provider.props.origin);
let firstReceived;
let updateReceived;
const initial = new Promise(resolve => { firstReceived = resolve; });
const updated = new Promise(resolve => { updateReceived = resolve; });
subscription = query.subscribe(result => {
if (result.data?.[0]?.name === 'first') firstReceived(result);
if (result.data?.[0]?.name === 'second') updateReceived(result);
});
assert.equal((await within(initial, 'anonymous SSE initial')).isAuthorized, true);
subject.next([{ id: '1', name: 'second' }]);
assert.equal((await within(updated, 'anonymous SSE update')).data[0].name, 'second');
} finally {
subscription?.unsubscribe();
if (subscription) await within(unsubscribeFinished, 'anonymous SSE unsubscribe');
resetSharedMultiplexer();
Globals.queryTransportMethod = previous.method;
Globals.queryDirectMode = previous.direct;
Globals.observableQueryTransferMode = previous.mode;
Globals.eventSourceFactory = previous.factory;
Globals.httpHeadersCallback = previous.headers;
globalThis.fetch = previous.fetch;
if (previous.eventSource === undefined) delete globalThis.EventSource;
else globalThis.EventSource = previous.eventSource;
await server.dispose();
await listening.close();
}
});

for (const kind of ['express', 'fastify', 'hono']) {
for (const [method, mode] of [
[QueryTransportMethod.WebSocket, 'full'], [QueryTransportMethod.WebSocket, 'delta'],
Expand Down
4 changes: 2 additions & 2 deletions ContractTests/Client/observable-origin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ for (const kind of ['express', 'fastify', 'hono']) {
{ authorization: 'Bearer bad' }), 401);
const sse = await openSse(listening.origin, listening.origin);
try {
assert.equal((await subscribe(listening.origin, sse.connectionId, 'https://evil.example')).status, 404);
assert.equal((await subscribe(listening.origin, sse.connectionId, 'https://evil.example')).status, 403);
assert.equal((await subscribe(listening.origin, sse.connectionId, listening.origin)).status, 200);
} finally { await sse.reader.cancel(); }
} finally { await server.dispose(); await listening.close(); }
Expand All @@ -92,7 +92,7 @@ for (const kind of ['express', 'fastify', 'hono']) {
{ authorization: 'Bearer alice' }), 403);
const sse = await openSse(listening.origin, 'https://app.example.com');
try {
assert.equal((await subscribe(listening.origin, sse.connectionId, listening.origin)).status, 404);
assert.equal((await subscribe(listening.origin, sse.connectionId, listening.origin)).status, 403);
assert.equal((await subscribe(listening.origin, sse.connectionId, 'https://app.example.com')).status, 200);
} finally { await sse.reader.cancel(); }
} finally { await server.dispose(); await listening.close(); }
Expand Down
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.24.0",
"version": "0.24.1",
"private": true,
"type": "module",
"dependencies": {
Expand Down
9 changes: 3 additions & 6 deletions Documentation/getting-started/continue-in-the-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,21 +210,18 @@ Create `src/main.tsx`:
import 'reflect-metadata';
import { createRoot } from 'react-dom/client';
import { Arc } from '@cratis/arc.react';
import { QueryTransportMethod } from '@cratis/arc/queries';
import { TaskBoard } from './TaskBoard';

createRoot(document.getElementById('root')!).render(
<Arc queryTransportMethod={QueryTransportMethod.WebSocket}>
<Arc>
<TaskBoard />
</Arc>
);
```

`<Arc>` gives every hook below it the same configuration: the API origin (here the page's own origin, which Vite forwards) and how live queries travel.

:::caution[Choose the WebSocket hub for an anonymous server]
`<Arc>` connects live queries through the server-sent events hub by default. On this server the SSE hub requires an authenticated caller, and the Tasks sample has no authentication, so the list would stay empty while the browser console reports `SSE hub connection error`. `queryTransportMethod={QueryTransportMethod.WebSocket}` uses the WebSocket hub at `/.cratis/queries/ws`, which accepts anonymous callers. An application with real sign-in can keep the default; see [Multiplexed observable queries](../queries/observable-query-demultiplexer.md).
:::
`<Arc>` uses the server-sent events hub by default. It accepts anonymous connections, and the Tasks sample's observable query permits anonymous subscriptions. See [Multiplexed observable queries](../queries/observable-query-demultiplexer.md) for its control-request security model.

## Run it

Expand All @@ -243,7 +240,7 @@ Open <http://127.0.0.1:5173>. The list shows any tasks you registered with `curl
| `!Loud` | `A title cannot begin with an exclamation mark` | The browser had no copy of this rule, so the server checked it and answered 400 |
| `Try the browser` | `Task registered.` | The command succeeded, and the list grows by one without a reload |

The last row is the observable query at work. The WebSocket hub subscribed to `observeAllTasks` when the page loaded. When `handle()` called `tasks.register(...)`, the sample's `BehaviorSubject` emitted the new list, Arc pushed it over the open connection, and the hook re-rendered the page. Register a task with `curl` from another terminal and it appears in the browser too.
The last row is the observable query at work. The SSE hub subscribed to `observeAllTasks` when the page loaded. When `handle()` called `tasks.register(...)`, the sample's `BehaviorSubject` emitted the new list, Arc pushed it over the open connection, and the hook re-rendered the page. Register a task with `curl` from another terminal and it appears in the browser too.

## Recap

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.24.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.24.1 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
2 changes: 1 addition & 1 deletion Documentation/queries/observable-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Direct WebSocket frames are `{"type":"Data","data":<query result>}`; a `Ping` re

## Use the installed client

The published `@cratis/arc` client subscribes through generated `ObservableQueryFor` proxies over the [multiplexed hub](observable-query-demultiplexer.md). The plain client defaults to the WebSocket hub. The `<Arc>` provider from `@cratis/arc.react` defaults to the SSE hub instead, and on this server the SSE hub requires an authenticated caller. For an application without sign-in, set `<Arc queryTransportMethod={QueryTransportMethod.WebSocket}>`, as [Continue in the browser](../getting-started/continue-in-the-browser.md) does.
The published `@cratis/arc` client subscribes through generated `ObservableQueryFor` proxies over the [multiplexed hub](observable-query-demultiplexer.md). The plain client defaults to the WebSocket hub. The `<Arc>` provider from `@cratis/arc.react` defaults to the SSE hub instead; both accept anonymous connections. Each subscription still passes through query authorization, so an anonymous caller can only observe queries that permit anonymous access. The default `<Arc>` configuration works for the [Tasks browser example](../getting-started/continue-in-the-browser.md) without switching transports.

For the direct transports above, set these before subscribing:

Expand Down
6 changes: 4 additions & 2 deletions Documentation/queries/observable-query-demultiplexer.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Carry many observable-query subscriptions over one WebSocket or ser

A dashboard with ten live widgets should not open ten sockets. The multiplexed hub carries every subscription from one client over a single connection, and it is the default transport of the published `@cratis/arc` client.

The plain `@cratis/arc` client uses the WebSocket hub by default. The `<Arc>` provider from `@cratis/arc.react` uses the SSE hub by default, which on this server requires an authenticated caller; set `<Arc queryTransportMethod={QueryTransportMethod.WebSocket}>` when your callers are anonymous.
The plain `@cratis/arc` client uses the WebSocket hub by default. The `<Arc>` provider from `@cratis/arc.react` uses the SSE hub by default. Both work with anonymous callers; every subscription still runs through query authorization.

## The WebSocket hub

Expand All @@ -29,7 +29,9 @@ hub.onmessage = event => {

## The server-sent-events hub

The SSE hub uses `GET /.cratis/queries/sse` for its `Connected` stream, plus `POST /.cratis/queries/sse/subscribe` and `/unsubscribe` controls. It requires a **trusted authenticated principal** on the stream and on every control request; anonymous callers cannot use it, which differs from Arc on .NET. Browser `EventSource` sends same-origin cookies but cannot set an `Authorization` header, so authenticate the stream with your application's real session cookie and send the same cookie with the control requests. A control request from a different principal or tenant returns the same 404 as an unknown connection ID; subscribing to an unauthorized query answers 401. The `.cratis-identity` display cookie is not a credential.
The SSE hub uses `GET /.cratis/queries/sse` for its `Connected` stream, plus `POST /.cratis/queries/sse/subscribe` and `/unsubscribe` controls. The stream can be opened anonymously. Its connection ID is crypto-random; a control request must match the opening caller's authentication state and tenant. Authenticated connections require the same authenticated identity on every control request, while anonymous callers cannot control authenticated connections (or vice versa). With anonymous connections, callers have no identity to distinguish them: when the host supplies a peer address, Arc for TypeScript additionally requires that address on control requests to match the opener. Without a peer address, other anonymous callers in the same tenant who learn the connection ID cannot be distinguished. This address check is deliberately stricter than Arc on .NET; do not treat it as user authentication, especially behind a shared proxy. Unknown or unowned connections return 404, and a query the caller may not access answers 401 with an `Unauthorized` frame.

Browser `EventSource` sends same-origin cookies but cannot set an `Authorization` header. If your queries require sign-in, use your application's real session cookie on the stream and controls; the `.cratis-identity` display cookie is not a credential. SSE controls require `application/json` (optionally `charset=utf-8`) and reject an untrusted browser `Origin` with 403. Same-origin is allowed by default; configure `query.allowedOrigins` for trusted cross-origin frontends. Anonymous per-caller connection limits group requests by peer address, or by tenant when no address is available.

## Configure the installed client

Expand Down
4 changes: 2 additions & 2 deletions Documentation/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_<Su
| --- | --- | --- | --- |
| Observable definitions and HTTP snapshots | Bounded | `{ observable: true }` model-bound queries and `defineObservableQuery` accept RxJS `BehaviorSubject`, `ReplaySubject`, `Subject`, `Observable`, async iterables, and structural subscribables. `CurrentValueSubject` remains supported but is deprecated. BehaviorSubject current values answer 200; sources without readable current values answer 202; bounded waits answer 408 or 500. RxJS is an optional peer of core for async-iterable-only consumers. | `Source/Core/for_ArcServer/when_handling_observable_snapshot`, `Source/Core/queries/observable/for_CurrentValueSubject`, `Source/Core/for_ArcApplicationBuilder/when_serving_model_bound_observables` |
| Direct SSE and WebSocket | Bounded | The query route streams direct result frames through the Express, Fastify, and Hono Node adapters and the standalone host; generated installed-client subscriptions receive initial and later results. | `Source/Core/queries/observable/for_directWebSocket`, `ContractTests/Client/observable-direct-sse.test.mjs`, `ContractTests/Client/observable-upgrade-lifecycle.test.mjs` |
| Multiplexed WebSocket and SSE hubs | Bounded | `/.cratis/queries/ws` and authenticated `/.cratis/queries/sse` with revisions, configurable keep-alive, and tombstones, on all three adapters and the installed 22.19.1 client. Anonymous SSE hub controls are not available, unlike .NET. | `Source/Core/queries/observable/for_HubConnection`, `.../for_SseHubTransport`, `.../for_SubscriptionRevisions`, `ContractTests/Client/observable-hub.test.mjs` |
| Multiplexed WebSocket and SSE hubs | Bounded | `/.cratis/queries/ws` and `/.cratis/queries/sse` accept anonymous connections, with revisions, configurable keep-alive, and tombstones on all three adapters and the installed 22.19.1 client (including the React provider's default SSE transport). SSE control requests must match the opener's authenticated identity or anonymous state and tenant; anonymous callers are also matched by peer address when available, unlike .NET. Query authorization is checked per subscription. | `Source/Core/queries/observable/for_HubConnection`, `.../for_SseHubTransport`, `.../for_SubscriptionRevisions`, `ContractTests/Client/observable-hub.test.mjs` |
| Full, delta, and legacy transfer | Bounded | `full`, `delta` (initial full, then change sets), and legacy full plus change set. The installed client does not rebuild delta arrays in subscription callbacks. | `Source/Core/queries/observable/for_ObservableTransfer` |
| Emission guards | Supported | Scoped guards `Allow`, `Suppress`, or `DenyAndTerminate` every rendered result; failures deny and are logged. | `Source/Core/queries/observable/for_ObservableQuerySession`, `Source/Core/for_ArcServer/when_opening_observable_query` |
| Query health | Supported, with a deliberate difference | `query: { enableObservableHealth: true }` exposes the authenticated caller's own hub connections only. .NET exposes broader anonymous metadata. | `Source/Core/for_ArcServer/when_reporting_caller_scoped_health.ts`, `.../when_handling_observable_health`, `Source/Core/queries/observable/for_FilteredHealthSource` |
Expand Down Expand Up @@ -132,7 +132,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_<Su
- **Anonymous caller on a protected operation.** With authentication handlers configured, Arc for TypeScript answers 401; Arc on .NET answers 403.
- **Policy context and authentication schemes.** .NET 22.23.0 evaluates named policies with scoped DI classes, a reflected target, and a command or query context; TypeScript also offers function policies, and its class policies receive an operation definition and an input and execution resource. Node scheme selection uses Arc handlers, not ASP.NET Core scheme composition, and scheme-protected observable queries are rejected at build. `jwtBearer()` is TypeScript-only. The paired 22.23.0 fixture checks named-policy allow and deny on commands and queries, but does not compare policy context objects or scheme selection.
- **Query health.** Caller-scoped and opt-in, instead of .NET's anonymous cross-caller view.
- **Anonymous SSE hub.** The SSE hub requires an authenticated principal.
- **Anonymous SSE hub ownership.** Like .NET, anonymous callers can open an SSE hub connection but have no identity that distinguishes them from other anonymous callers. TypeScript additionally binds anonymous controls to the opening peer address when the host supplies one; without an address, callers in the same tenant who know the random connection ID cannot be distinguished. Authenticated connections require the same authenticated identity, and anonymous callers cannot control them.
- **Observable wait bounds.** `waitForFirstResultTimeout` is capped at 120 seconds, and an unrecognized boolean is rejected instead of ignored.
- **Message texts.** Malformed requests say `Malformed request`, and redacted exceptions say `An unexpected error occurred`; Arc on .NET uses different texts.
- **Preparation and binding.** A model-bound `provide()` passes one value as the first `handle()` argument; several values need `tuple(...)` and `provided(Type)` markers, where .NET infers assignable types. Without generated metadata, standard-mode queries need ordered descriptors.
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.24.0, the version of the source preview. **None is published to npm**; reference them from a clone with the `workspace:^` protocol. They ship ES modules only.
Every package in this repository is at version 0.24.1, the version of the source preview. **None is published to npm**; reference them from a clone with the `workspace:^` protocol. They ship ES modules only.

## Server packages

Expand Down
Loading
Loading