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
|
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:
|
||||||
|
|
||||||
|
|||||||
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",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
```
|
||||||
|
|||||||
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 * 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
61
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
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