feat: add fresh HiveCast SDK demos

This commit is contained in:
Ubuntu 2026-06-08 06:41:34 +00:00
parent 8a9b788f76
commit 4e62f1fbc7
17 changed files with 984 additions and 0 deletions

View File

@ -169,6 +169,28 @@ and preserved under `archive/sdk-provider-overlay-candidates/`. It belongs to a
provider overlay that can write the same broker config shape consumed by the SDK provider overlay that can write the same broker config shape consumed by the SDK
CLI. CLI.
HiveCast-managed SDK demos, API key required:
```bash
MATRIX_CLOUD=https://<your-hivecast-cloud> \
MATRIX_SPACE=<your-space-root> \
MATRIX_API_KEY=<your-hivecast-api-key> \
pnpm prove:fresh-samples
```
That proof builds and runs two fresh package-author demos:
- `examples/fresh-server-actor` exchanges the API key for an actor-scoped
credential, connects to HiveCast NATS TCP, mounts `fresh.server.echo`, and
calls it over the Matrix runtime/NATS transport path.
- `examples/fresh-web-page` is served by Vite, not Matrix Web or HiveCast HTTP.
Its Vite dev endpoint exchanges the API key for an actor-scoped browser
credential, the page connects to HiveCast NATS WebSocket, mounts
`fresh.web.echo`, and calls it in a real browser.
The API key stays server-side for the web demo. The browser receives only the
short-lived actor JWT/seed returned by the HiveCast credential exchange.
## Repository Layout ## Repository Layout
```text ```text
@ -184,6 +206,7 @@ packages/
scripts/ scripts/
demo:standalone demo:standalone
prove-external-consumer.mjs prove-external-consumer.mjs
prove-fresh-samples.mjs
``` ```
## Current Status ## Current Status
@ -198,6 +221,8 @@ Done and proven:
a `MatrixActor`, and call it through Matrix runtime/transport. a `MatrixActor`, and call it through Matrix runtime/transport.
- `matrix package run` can mount and check a package against an explicit NATS - `matrix package run` can mount and check a package against an explicit NATS
broker without provider-specific API key exchange. broker without provider-specific API key exchange.
- Fresh server and browser examples can run against HiveCast-issued
actor-scoped NATS credentials.
Still planned: Still planned:

View File

@ -0,0 +1,31 @@
# Fresh Server Actor Demo
This is a fresh package-author sample. It does not use Host Service, HiveDock,
Device Link, Matrix Web, or any Matrix/HiveCast HTTP server.
It uses:
- `@open-matrix/sdk` for actor/runtime/transport
- a HiveCast API key for `/api/install/actor-credential`
- NATS for the actual actor bus
## Run
```bash
export MATRIX_CLOUD=https://<your-hivecast-cloud>
export MATRIX_SPACE=<your-space-root>
export MATRIX_API_KEY=<your-hivecast-api-key>
pnpm install
pnpm --filter matrix-sdk-example-fresh-server-actor build
pnpm --filter matrix-sdk-example-fresh-server-actor check
```
Expected result:
```json
{
"ok": true,
"demo": "fresh-server-actor"
}
```

View File

@ -0,0 +1,19 @@
{
"name": "matrix-sdk-example-fresh-server-actor",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"check": "node dist/server.js --check"
},
"dependencies": {
"@open-matrix/sdk": "workspace:*",
"nats": "^2.29.3"
},
"devDependencies": {
"@types/node": "^25.0.10",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,165 @@
import { connect as connectNats, jwtAuthenticator, type NatsConnection } from 'nats';
import {
MatrixActor,
MatrixRuntime,
NatsTransport,
TopicRouter,
exchangeHiveCastActorCredential,
} from '@open-matrix/sdk';
class FreshServerEchoActor extends MatrixActor {
static accepts = {
echo: {},
getStatus: {},
};
onEcho(payload: { message?: string }) {
return {
ok: true,
message: payload.message ?? null,
actor: 'FreshServerEchoActor',
};
}
onGetStatus() {
return {
ok: true,
actor: 'FreshServerEchoActor',
mode: 'server',
};
}
}
function env(name: string): string | undefined {
const value = process.env[name];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function requiredEnv(name: string): string {
const value = env(name);
if (!value) throw new Error(`${name}_REQUIRED`);
return value;
}
function connectWithJwt(input: {
readonly natsUrl: string;
readonly root: string;
readonly jwt: string;
readonly seed: string;
}): Promise<NatsConnection> {
const encoder = new TextEncoder();
return connectNats({
servers: [input.natsUrl],
name: `matrix-sdk-fresh-server-${input.root}-${process.pid}`,
timeout: 5000,
authenticator: jwtAuthenticator(
() => input.jwt,
() => encoder.encode(input.seed),
),
});
}
async function request(
transport: NatsTransport,
mount: string,
op: string,
payload: unknown,
): Promise<unknown> {
const correlationId = `fresh-server-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const replyTo = `$replies.${correlationId}`;
let unsubscribe = () => {};
const response = new Promise<unknown>((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`REQUEST_TIMEOUT: ${mount}.${op}`));
}, 5000);
unsubscribe = transport.subscribe(replyTo, (value) => {
clearTimeout(timeout);
unsubscribe();
resolve(value);
});
});
transport.publish(TopicRouter.inbox(mount), {
op,
payload,
replyTo,
correlationId,
});
return response;
}
async function main(): Promise<void> {
const check = process.argv.includes('--check');
const cloud = requiredEnv('MATRIX_CLOUD');
const apiKey = requiredEnv('MATRIX_API_KEY');
const space = requiredEnv('MATRIX_SPACE');
const mount = env('MATRIX_MOUNT') ?? 'fresh.server.echo';
const exchanged = await exchangeHiveCastActorCredential({
cloud,
apiKey,
space,
}, {
mount,
actorId: 'FreshServerEchoActor',
packageId: 'matrix-sdk-example-fresh-server-actor',
runtimeId: `fresh-server-${process.pid}`,
accepts: FreshServerEchoActor.accepts,
ttlSeconds: 300,
});
if (!exchanged.transport.natsUrl) {
throw new Error('HiveCast actor credential response did not include transport.natsUrl');
}
const connection = await connectWithJwt({
natsUrl: exchanged.transport.natsUrl,
root: exchanged.root,
jwt: exchanged.credentials.jwt,
seed: exchanged.credentials.seed,
});
const transport = new NatsTransport(connection, { root: exchanged.root });
const runtime = new MatrixRuntime({ transport, logging: false });
try {
await runtime.create(FreshServerEchoActor, mount);
await connection.flush();
const result = check
? await request(transport, mount, 'echo', { message: 'hello-from-fresh-server-demo' })
: undefined;
console.log(JSON.stringify({
ok: true,
demo: 'fresh-server-actor',
cloud,
space,
root: exchanged.root,
mount,
natsUrl: exchanged.transport.natsUrl,
credentialPipeline: exchanged.credentialPipeline,
...(result ? { check: result } : {}),
}, null, 2));
if (!check) {
process.once('SIGINT', () => {
void runtime.shutdown().finally(() => process.exit(0));
});
process.once('SIGTERM', () => {
void runtime.shutdown().finally(() => process.exit(0));
});
await new Promise(() => undefined);
}
} finally {
if (check) {
await runtime.shutdown();
if (!connection.isClosed()) await connection.close();
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});

View File

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"noEmit": false,
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@ -0,0 +1,29 @@
# Fresh Web Page Actor Demo
This is a fresh browser sample. It is served by Vite, not by Matrix Web,
HiveCast gateway, Host Service, or any Matrix/HiveCast HTTP service.
It uses:
- `@open-matrix/sdk` in the browser
- Vite only for static page serving and a tiny dev-only credential endpoint
- a HiveCast API key on the dev server side
- HiveCast `/api/install/actor-credential`
- NATS WebSocket for the actual actor bus
The API key is never bundled into the browser. Vite reads it from environment
variables and exchanges it for a short-lived actor credential.
## Run
```bash
export MATRIX_CLOUD=https://<your-hivecast-cloud>
export MATRIX_SPACE=<your-space-root>
export MATRIX_API_KEY=<your-hivecast-api-key>
pnpm install
pnpm --filter matrix-sdk-example-fresh-web-page dev
```
Open the printed Vite URL. The page mounts a browser actor and calls it over
HiveCast-backed NATS WebSocket.

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>Fresh Matrix Web Actor Demo</title>
</head>
<body>
<main>
<h1>Fresh Matrix Web Actor Demo</h1>
<fresh-web-actor-demo></fresh-web-actor-demo>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,19 @@
{
"name": "matrix-sdk-example-fresh-web-page",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 127.0.0.1",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@open-matrix/sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.0.10",
"typescript": "^5.7.0",
"vite": "^5.4.21"
}
}

View File

@ -0,0 +1,111 @@
import {
MatrixActor,
MatrixRuntime,
TopicRouter,
createBrowserNatsTransport,
} from '@open-matrix/sdk';
import type { HiveCastActorCredential } from '@open-matrix/sdk';
class FreshBrowserEchoActor extends MatrixActor {
static accepts = {
echo: {},
getStatus: {},
};
onEcho(payload: { message?: string }) {
return {
ok: true,
message: payload.message ?? null,
actor: 'FreshBrowserEchoActor',
};
}
onGetStatus() {
return {
ok: true,
actor: 'FreshBrowserEchoActor',
mode: 'browser',
};
}
}
async function request(
transport: Awaited<ReturnType<typeof createBrowserNatsTransport>>,
mount: string,
op: string,
payload: unknown,
): Promise<unknown> {
const correlationId = `fresh-web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const replyTo = `$replies.${correlationId}`;
let unsubscribe = () => {};
const response = new Promise<unknown>((resolve, reject) => {
const timeout = window.setTimeout(() => {
unsubscribe();
reject(new Error(`REQUEST_TIMEOUT: ${mount}.${op}`));
}, 5000);
unsubscribe = transport.subscribe(replyTo, (value) => {
window.clearTimeout(timeout);
unsubscribe();
resolve(value);
});
});
transport.publish(TopicRouter.inbox(mount), {
op,
payload,
replyTo,
correlationId,
});
return response;
}
class FreshWebActorDemoElement extends HTMLElement {
private readonly _status = document.createElement('pre');
connectedCallback(): void {
this.innerHTML = '';
this._status.textContent = 'Connecting to HiveCast...';
this.append(this._status);
void this._run();
}
private async _run(): Promise<void> {
const credentialResponse = await fetch('/matrix/actor-credential', { method: 'POST' });
const credential = await credentialResponse.json() as HiveCastActorCredential;
if (!credentialResponse.ok || credential.ok !== true) {
throw new Error(`HiveCast credential exchange failed: ${JSON.stringify(credential)}`);
}
if (!credential.transport.natsWsUrl) {
throw new Error('HiveCast actor credential response did not include transport.natsWsUrl');
}
const transport = await createBrowserNatsTransport({
root: credential.root,
wsUrl: credential.transport.natsWsUrl,
jwtProvider: async () => ({
jwt: credential.credentials.jwt,
seed: credential.credentials.seed,
}),
});
const runtime = new MatrixRuntime({ transport, logging: false });
await runtime.create(FreshBrowserEchoActor, credential.mount);
const result = await request(transport, credential.mount, 'echo', {
message: 'hello-from-fresh-web-page-demo',
});
this._status.textContent = JSON.stringify({
ok: true,
demo: 'fresh-web-page',
cloud: credential.cloud,
space: credential.space,
root: credential.root,
mount: credential.mount,
natsWsUrl: credential.transport.natsWsUrl,
credentialPipeline: credential.credentialPipeline,
check: result,
}, null, 2);
}
}
customElements.define('fresh-web-actor-demo', FreshWebActorDemoElement);

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true
},
"include": ["src/**/*.ts", "vite.config.ts"]
}

View File

@ -0,0 +1,58 @@
import { defineConfig, type Plugin } from 'vite';
import { exchangeHiveCastActorCredential } from '@open-matrix/sdk';
function env(name: string): string | undefined {
const value = process.env[name];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function requiredEnv(name: string): string {
const value = env(name);
if (!value) throw new Error(`${name}_REQUIRED`);
return value;
}
function sendJson(res: { statusCode: number; setHeader(name: string, value: string): void; end(body: string): void }, status: number, body: unknown): void {
const text = JSON.stringify(body);
res.statusCode = status;
res.setHeader('content-type', 'application/json');
res.end(text);
}
function hiveCastActorCredentialPlugin(): Plugin {
return {
name: 'fresh-hivecast-actor-credential',
configureServer(server) {
server.middlewares.use('/matrix/actor-credential', async (req, res) => {
if (req.method !== 'POST') {
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
return;
}
try {
const credential = await exchangeHiveCastActorCredential({
cloud: requiredEnv('MATRIX_CLOUD'),
apiKey: requiredEnv('MATRIX_API_KEY'),
space: requiredEnv('MATRIX_SPACE'),
}, {
mount: env('MATRIX_WEB_MOUNT') ?? 'fresh.web.echo',
actorId: 'FreshBrowserEchoActor',
packageId: 'matrix-sdk-example-fresh-web-page',
runtimeId: `fresh-web-${process.pid}`,
accepts: { echo: {}, getStatus: {} },
ttlSeconds: 300,
});
sendJson(res, 200, credential);
} catch (error) {
sendJson(res, 500, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
},
};
}
export default defineConfig({
plugins: [hiveCastActorCredentialPlugin()],
});

View File

@ -13,6 +13,7 @@
"demo:standalone": "node examples/standalone-greeter/run-demo.mjs", "demo:standalone": "node examples/standalone-greeter/run-demo.mjs",
"prove:cli": "pnpm --filter @open-matrix/cli build && pnpm --filter @open-matrix/cli test && pnpm run demo:standalone", "prove:cli": "pnpm --filter @open-matrix/cli build && pnpm --filter @open-matrix/cli test && pnpm run demo:standalone",
"prove:external-consumer": "node scripts/prove-external-consumer.mjs", "prove:external-consumer": "node scripts/prove-external-consumer.mjs",
"prove:fresh-samples": "node scripts/prove-fresh-samples.mjs",
"prove": "pnpm run clean && pnpm run build && pnpm run test && pnpm run type-check && pnpm run pack:all" "prove": "pnpm run clean && pnpm run build && pnpm run test && pnpm run type-check && pnpm run pack:all"
}, },
"dependencies": { "dependencies": {
@ -26,6 +27,7 @@
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"glob": "^13.0.6", "glob": "^13.0.6",
"playwright": "^1.60.0",
"tsx": "^4.15.6", "tsx": "^4.15.6",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }

View File

@ -14,3 +14,40 @@ browser host, contracts, federation, and Omega kernel facade.
Import browser app build utilities directly from `@open-matrix/browser-kit` so Import browser app build utilities directly from `@open-matrix/browser-kit` so
headless actor packages can import `@open-matrix/sdk` without loading headless actor packages can import `@open-matrix/sdk` without loading
browser-only custom element code. browser-only custom element code.
## HiveCast-Managed Transport
For HiveCast-backed actors, use a HiveCast API key to exchange for short-lived
actor-scoped NATS credentials:
```ts
import { exchangeHiveCastActorCredential } from '@open-matrix/sdk';
const credential = await exchangeHiveCastActorCredential({
cloud: process.env.MATRIX_CLOUD!,
space: process.env.MATRIX_SPACE!,
apiKey: process.env.MATRIX_API_KEY!,
}, {
mount: 'fresh.server.echo',
accepts: { echo: {} },
});
```
The exchange returns NATS URLs plus a JWT/seed pair for the requested mount.
Server actors use `NatsTransport` with `credential.transport.natsUrl`; browser
actors use `createBrowserNatsTransport` with
`credential.transport.natsWsUrl`.
Runnable examples:
- `examples/fresh-server-actor`
- `examples/fresh-web-page`
Proof:
```bash
MATRIX_CLOUD=https://<your-hivecast-cloud> \
MATRIX_SPACE=<your-space-root> \
MATRIX_API_KEY=<your-hivecast-api-key> \
pnpm run prove:fresh-samples
```

View File

@ -0,0 +1,177 @@
export interface HiveCastProviderConfig {
readonly cloud: string;
readonly apiKey: string;
readonly space: string;
readonly profile?: string;
readonly fetch?: typeof fetch;
}
export interface HiveCastActorCredentialRequest {
readonly mount: string;
readonly actorId?: string;
readonly packageId?: string;
readonly runtimeId?: string;
readonly accepts?: Record<string, unknown>;
readonly emits?: Record<string, unknown>;
readonly subscribes?: Record<string, unknown>;
readonly ttlSeconds?: number;
}
export interface HiveCastCredentialPair {
readonly jwt: string;
readonly seed: string;
readonly userPublicKey?: string;
readonly expiresAt?: string;
}
export interface HiveCastTransportInfo {
readonly root: string;
readonly natsUrl?: string;
readonly natsWsUrl?: string;
readonly leafnodeUrl?: string;
}
export interface HiveCastActorCredential {
readonly ok: true;
readonly cloud: string;
readonly space: string;
readonly root: string;
readonly mount: string;
readonly effectiveMount?: string;
readonly transport: HiveCastTransportInfo;
readonly credentials: HiveCastCredentialPair;
readonly key?: Record<string, unknown>;
readonly source?: string;
readonly credentialPipeline?: string;
}
export interface HiveCastKeyInfo {
readonly ok: true;
readonly cloud: string;
readonly space: string;
readonly key?: Record<string, unknown>;
readonly principal?: Record<string, unknown>;
readonly profile?: Record<string, unknown>;
}
function requiredString(value: unknown, label: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`HiveCast response missing ${label}`);
}
return value.trim();
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function normalizeCloud(cloud: string): string {
const normalized = cloud.trim().replace(/\/+$/g, '');
if (!normalized) throw new Error('HiveCast cloud URL is required');
return normalized;
}
async function postHiveCastJson(
config: HiveCastProviderConfig,
path: string,
body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const cloud = normalizeCloud(config.cloud);
const fetchImpl = config.fetch ?? globalThis.fetch;
if (typeof fetchImpl !== 'function') {
throw new Error('HiveCast provider requires fetch');
}
const response = await fetchImpl(`${cloud}${path}`, {
method: 'POST',
headers: {
authorization: `Bearer ${config.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
const text = await response.text();
const parsed = text.trim() ? record(JSON.parse(text)) : {};
if (!response.ok || parsed.ok !== true) {
const error = optionalString(parsed.error) ?? `HTTP_${response.status}`;
throw new Error(`HiveCast ${path} failed: ${error}`);
}
return parsed;
}
export async function validateHiveCastApiKey(config: HiveCastProviderConfig): Promise<HiveCastKeyInfo> {
const cloud = normalizeCloud(config.cloud);
const body = await postHiveCastJson(config, '/api/install/key-info', {
space: config.space,
...(config.profile ? { profile: config.profile } : {}),
});
return {
ok: true,
cloud,
space: config.space,
key: record(body.key),
principal: record(body.principal),
profile: record(body.profile),
};
}
export async function exchangeHiveCastActorCredential(
config: HiveCastProviderConfig,
request: HiveCastActorCredentialRequest,
): Promise<HiveCastActorCredential> {
const cloud = normalizeCloud(config.cloud);
if (!config.apiKey.trim()) throw new Error('HiveCast API key is required');
if (!config.space.trim()) throw new Error('HiveCast space is required');
if (!request.mount.trim()) throw new Error('HiveCast actor credential exchange requires mount');
const body = await postHiveCastJson(config, '/api/install/actor-credential', {
space: config.space,
mount: request.mount,
...(config.profile ? { profile: config.profile } : {}),
...(request.actorId ? { actorId: request.actorId } : {}),
...(request.packageId ? { packageId: request.packageId } : {}),
...(request.runtimeId ? { runtimeId: request.runtimeId } : {}),
...(request.accepts ? { accepts: request.accepts } : {}),
...(request.emits ? { emits: request.emits } : {}),
...(request.subscribes ? { subscribes: request.subscribes } : {}),
...(request.ttlSeconds ? { ttlSeconds: request.ttlSeconds } : {}),
});
const transport = record(body.transport);
const credentials = record(body.credentials);
const space = record(body.space);
const root = optionalString(transport.root)
?? optionalString(space.authorityRoot)
?? optionalString(body.authorityRoot)
?? optionalString(body.root)
?? config.space;
return {
ok: true,
cloud,
space: config.space,
root,
mount: request.mount,
effectiveMount: optionalString(body.effectiveMount),
transport: {
root,
natsUrl: optionalString(transport.natsUrl),
natsWsUrl: optionalString(transport.natsWsUrl),
leafnodeUrl: optionalString(transport.leafnodeUrl),
},
credentials: {
jwt: requiredString(credentials.jwt, 'credentials.jwt'),
seed: requiredString(credentials.seed, 'credentials.seed'),
userPublicKey: optionalString(credentials.userPublicKey),
expiresAt: optionalString(credentials.expiresAt),
},
key: record(body.key),
source: optionalString(body.source),
credentialPipeline: optionalString(body.credentialPipeline),
};
}

View File

@ -1,4 +1,8 @@
export * from '@open-matrix/core'; export * from '@open-matrix/core';
export { createBrowserNatsTransport } from '@open-matrix/core/transport/createBrowserNatsTransport';
export type { ICreateBrowserNatsTransportConfig } from '@open-matrix/core/transport/createBrowserNatsTransport';
export * from './hivecast.js';
export * as BrowserHost from '@open-matrix/browser-host'; export * as BrowserHost from '@open-matrix/browser-host';
export * as Contracts from '@open-matrix/contracts'; export * as Contracts from '@open-matrix/contracts';

61
pnpm-lock.yaml generated
View File

@ -33,6 +33,9 @@ importers:
glob: glob:
specifier: ^13.0.6 specifier: ^13.0.6
version: 13.0.6 version: 13.0.6
playwright:
specifier: ^1.60.0
version: 1.60.0
tsx: tsx:
specifier: ^4.15.6 specifier: ^4.15.6
version: 4.22.4 version: 4.22.4
@ -40,6 +43,38 @@ importers:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
examples/fresh-server-actor:
dependencies:
'@open-matrix/sdk':
specifier: workspace:*
version: link:../../packages/sdk
nats:
specifier: ^2.29.3
version: 2.29.3
devDependencies:
'@types/node':
specifier: ^25.0.10
version: 25.9.2
typescript:
specifier: ^5.7.0
version: 5.9.3
examples/fresh-web-page:
dependencies:
'@open-matrix/sdk':
specifier: workspace:*
version: link:../../packages/sdk
devDependencies:
'@types/node':
specifier: ^25.0.10
version: 25.9.2
typescript:
specifier: ^5.7.0
version: 5.9.3
vite:
specifier: ^5.4.21
version: 5.4.21(@types/node@25.9.2)
examples/standalone-greeter: examples/standalone-greeter:
dependencies: dependencies:
'@open-matrix/core': '@open-matrix/core':
@ -847,6 +882,11 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -896,6 +936,16 @@ packages:
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.15: postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -1405,6 +1455,9 @@ snapshots:
'@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0 '@esbuild/win32-x64': 0.28.0
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@ -1445,6 +1498,14 @@ snapshots:
picocolors@1.1.1: {} picocolors@1.1.1: {}
playwright-core@1.60.0: {}
playwright@1.60.0:
dependencies:
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.15: postcss@8.5.15:
dependencies: dependencies:
nanoid: 3.3.12 nanoid: 3.3.12

View File

@ -0,0 +1,202 @@
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { chromium } from 'playwright';
const root = resolve(new URL('..', import.meta.url).pathname);
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? root,
stdio: options.stdio ?? 'inherit',
env: process.env,
encoding: 'utf8',
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed with exit ${result.status}`);
}
return result;
}
function delay(ms) {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}
function freePort() {
return new Promise((resolvePort, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolvePort(port));
});
});
}
async function waitForHttp(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
lastError = new Error(`${url} returned ${response.status}`);
} catch (error) {
lastError = error;
}
await delay(100);
}
throw lastError ?? new Error(`Timed out waiting for ${url}`);
}
function hasHiveCastEnv() {
return Boolean(process.env.MATRIX_API_KEY && process.env.MATRIX_CLOUD && process.env.MATRIX_SPACE);
}
function chromiumLaunchOptions() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE;
if (explicit) return { headless: true, executablePath: explicit };
for (const candidate of ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser']) {
if (existsSync(candidate)) return { headless: true, executablePath: candidate };
}
return { headless: true };
}
function assertContains(file, text) {
const body = readFileSync(resolve(root, file), 'utf8');
if (!body.includes(text)) {
throw new Error(`${file} does not contain expected text: ${text}`);
}
}
function parseLastJsonObject(stdout) {
const text = stdout.trim();
for (let i = 0; i < text.length; i += 1) {
if (text[i] !== '{') continue;
try {
return JSON.parse(text.slice(i));
} catch {
// Keep scanning later object starts.
}
}
throw new Error(`No JSON proof object found in stdout:\n${stdout}`);
}
async function proveWebLive() {
const port = await freePort();
const origin = `http://127.0.0.1:${port}`;
const vite = spawn('pnpm', [
'--filter',
'matrix-sdk-example-fresh-web-page',
'exec',
'vite',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
], {
cwd: root,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
vite.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
vite.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
try {
await waitForHttp(origin, 15_000);
const browser = await chromium.launch(chromiumLaunchOptions());
const page = await browser.newPage();
const consoleErrors = [];
const failedRequests = [];
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
page.on('requestfailed', (request) => {
failedRequests.push({
url: request.url(),
failure: request.failure()?.errorText ?? 'unknown',
});
});
try {
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 15_000 });
await page.waitForFunction(() => {
const text = document.querySelector('pre')?.textContent ?? '';
return text.includes('"ok": true') && text.includes('fresh-web-page');
}, { timeout: 20_000 });
const body = await page.locator('pre').innerText();
const proof = JSON.parse(body);
if (proof.ok !== true || proof.demo !== 'fresh-web-page') {
throw new Error(`Unexpected fresh web page proof: ${body}`);
}
if (consoleErrors.length > 0 || failedRequests.length > 0) {
throw new Error(`Browser proof had errors: ${JSON.stringify({ consoleErrors, failedRequests })}`);
}
return {
...proof,
origin,
consoleErrors,
failedRequests,
};
} finally {
await browser.close();
}
} catch (error) {
throw new Error([
error instanceof Error ? error.message : String(error),
stdout ? `vite stdout:\n${stdout}` : '',
stderr ? `vite stderr:\n${stderr}` : '',
].filter(Boolean).join('\n'));
} finally {
vite.kill('SIGTERM');
await delay(250);
if (!vite.killed) vite.kill('SIGKILL');
}
}
run('pnpm', ['--filter', '@open-matrix/core', 'build']);
run('pnpm', ['--filter', '@open-matrix/sdk', 'build']);
run('pnpm', ['--filter', 'matrix-sdk-example-fresh-server-actor', 'build']);
run('pnpm', ['--filter', 'matrix-sdk-example-fresh-web-page', 'build']);
assertContains('examples/fresh-server-actor/src/server.ts', "from '@open-matrix/sdk'");
assertContains('examples/fresh-server-actor/src/server.ts', 'exchangeHiveCastActorCredential');
assertContains('examples/fresh-web-page/src/main.ts', "from '@open-matrix/sdk'");
assertContains('examples/fresh-web-page/vite.config.ts', 'exchangeHiveCastActorCredential');
assertContains('examples/fresh-web-page/README.md', 'served by Vite, not by Matrix Web');
const live = hasHiveCastEnv();
let serverLive = null;
let webLive = null;
if (live) {
const result = run('pnpm', ['--filter', 'matrix-sdk-example-fresh-server-actor', 'check'], { stdio: 'pipe' });
serverLive = parseLastJsonObject(result.stdout);
if (serverLive.ok !== true || serverLive.demo !== 'fresh-server-actor') {
throw new Error(`Unexpected fresh server actor proof: ${result.stdout}`);
}
webLive = await proveWebLive();
}
console.log(JSON.stringify({
ok: true,
proof: 'fresh-samples',
built: [
'matrix-sdk-example-fresh-server-actor',
'matrix-sdk-example-fresh-web-page',
],
hivecastEnvPresent: live,
serverLive,
webLive,
}, null, 2));