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
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
```text
@ -184,6 +206,7 @@ packages/
scripts/
demo:standalone
prove-external-consumer.mjs
prove-fresh-samples.mjs
```
## Current Status
@ -198,6 +221,8 @@ Done and proven:
a `MatrixActor`, and call it through Matrix runtime/transport.
- `matrix package run` can mount and check a package against an explicit NATS
broker without provider-specific API key exchange.
- Fresh server and browser examples can run against HiveCast-issued
actor-scoped NATS credentials.
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",
"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: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"
},
"dependencies": {
@ -26,6 +27,7 @@
"@types/node": "^25.0.10",
"esbuild": "^0.27.3",
"glob": "^13.0.6",
"playwright": "^1.60.0",
"tsx": "^4.15.6",
"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
headless actor packages can import `@open-matrix/sdk` without loading
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 { 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 Contracts from '@open-matrix/contracts';

61
pnpm-lock.yaml generated
View File

@ -33,6 +33,9 @@ importers:
glob:
specifier: ^13.0.6
version: 13.0.6
playwright:
specifier: ^1.60.0
version: 1.60.0
tsx:
specifier: ^4.15.6
version: 4.22.4
@ -40,6 +43,38 @@ importers:
specifier: ^5.7.0
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:
dependencies:
'@open-matrix/core':
@ -847,6 +882,11 @@ packages:
engines: {node: '>=18'}
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:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -896,6 +936,16 @@ packages:
picocolors@1.1.1:
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:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
@ -1405,6 +1455,9 @@ snapshots:
'@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -1445,6 +1498,14 @@ snapshots:
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:
dependencies:
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));