feat: add fresh HiveCast SDK demos
This commit is contained in:
parent
8a9b788f76
commit
4e62f1fbc7
25
README.md
25
README.md
@ -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:
|
||||
|
||||
|
||||
31
examples/fresh-server-actor/README.md
Normal file
31
examples/fresh-server-actor/README.md
Normal 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"
|
||||
}
|
||||
```
|
||||
19
examples/fresh-server-actor/package.json
Normal file
19
examples/fresh-server-actor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
165
examples/fresh-server-actor/src/server.ts
Normal file
165
examples/fresh-server-actor/src/server.ts
Normal 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);
|
||||
});
|
||||
15
examples/fresh-server-actor/tsconfig.json
Normal file
15
examples/fresh-server-actor/tsconfig.json
Normal 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"]
|
||||
}
|
||||
29
examples/fresh-web-page/README.md
Normal file
29
examples/fresh-web-page/README.md
Normal 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.
|
||||
16
examples/fresh-web-page/index.html
Normal file
16
examples/fresh-web-page/index.html
Normal 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>
|
||||
19
examples/fresh-web-page/package.json
Normal file
19
examples/fresh-web-page/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
111
examples/fresh-web-page/src/main.ts
Normal file
111
examples/fresh-web-page/src/main.ts
Normal 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);
|
||||
13
examples/fresh-web-page/tsconfig.json
Normal file
13
examples/fresh-web-page/tsconfig.json
Normal 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"]
|
||||
}
|
||||
58
examples/fresh-web-page/vite.config.ts
Normal file
58
examples/fresh-web-page/vite.config.ts
Normal 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()],
|
||||
});
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
177
packages/sdk/src/hivecast.ts
Normal file
177
packages/sdk/src/hivecast.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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
61
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
202
scripts/prove-fresh-samples.mjs
Normal file
202
scripts/prove-fresh-samples.mjs
Normal 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));
|
||||
Loading…
x
Reference in New Issue
Block a user