diff --git a/README.md b/README.md index cb609fb..3c568bb 100644 --- a/README.md +++ b/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:// \ +MATRIX_SPACE= \ +MATRIX_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: diff --git a/examples/fresh-server-actor/README.md b/examples/fresh-server-actor/README.md new file mode 100644 index 0000000..9a4504b --- /dev/null +++ b/examples/fresh-server-actor/README.md @@ -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:// +export MATRIX_SPACE= +export MATRIX_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" +} +``` diff --git a/examples/fresh-server-actor/package.json b/examples/fresh-server-actor/package.json new file mode 100644 index 0000000..e8aeced --- /dev/null +++ b/examples/fresh-server-actor/package.json @@ -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" + } +} diff --git a/examples/fresh-server-actor/src/server.ts b/examples/fresh-server-actor/src/server.ts new file mode 100644 index 0000000..181fb84 --- /dev/null +++ b/examples/fresh-server-actor/src/server.ts @@ -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 { + 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 { + const correlationId = `fresh-server-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const replyTo = `$replies.${correlationId}`; + let unsubscribe = () => {}; + const response = new Promise((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 { + 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); +}); diff --git a/examples/fresh-server-actor/tsconfig.json b/examples/fresh-server-actor/tsconfig.json new file mode 100644 index 0000000..b8234db --- /dev/null +++ b/examples/fresh-server-actor/tsconfig.json @@ -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"] +} diff --git a/examples/fresh-web-page/README.md b/examples/fresh-web-page/README.md new file mode 100644 index 0000000..15e3978 --- /dev/null +++ b/examples/fresh-web-page/README.md @@ -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:// +export MATRIX_SPACE= +export MATRIX_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. diff --git a/examples/fresh-web-page/index.html b/examples/fresh-web-page/index.html new file mode 100644 index 0000000..d4f272a --- /dev/null +++ b/examples/fresh-web-page/index.html @@ -0,0 +1,16 @@ + + + + + + + Fresh Matrix Web Actor Demo + + +
+

Fresh Matrix Web Actor Demo

+ +
+ + + diff --git a/examples/fresh-web-page/package.json b/examples/fresh-web-page/package.json new file mode 100644 index 0000000..a0009ea --- /dev/null +++ b/examples/fresh-web-page/package.json @@ -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" + } +} diff --git a/examples/fresh-web-page/src/main.ts b/examples/fresh-web-page/src/main.ts new file mode 100644 index 0000000..f03a80d --- /dev/null +++ b/examples/fresh-web-page/src/main.ts @@ -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>, + mount: string, + op: string, + payload: unknown, +): Promise { + const correlationId = `fresh-web-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const replyTo = `$replies.${correlationId}`; + let unsubscribe = () => {}; + const response = new Promise((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 { + 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); diff --git a/examples/fresh-web-page/tsconfig.json b/examples/fresh-web-page/tsconfig.json new file mode 100644 index 0000000..9fd4f04 --- /dev/null +++ b/examples/fresh-web-page/tsconfig.json @@ -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"] +} diff --git a/examples/fresh-web-page/vite.config.ts b/examples/fresh-web-page/vite.config.ts new file mode 100644 index 0000000..644b744 --- /dev/null +++ b/examples/fresh-web-page/vite.config.ts @@ -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()], +}); diff --git a/package.json b/package.json index c5e5bbc..ffe10ec 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5e77567..9dd28a5 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -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:// \ +MATRIX_SPACE= \ +MATRIX_API_KEY= \ +pnpm run prove:fresh-samples +``` diff --git a/packages/sdk/src/hivecast.ts b/packages/sdk/src/hivecast.ts new file mode 100644 index 0000000..7a77482 --- /dev/null +++ b/packages/sdk/src/hivecast.ts @@ -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; + readonly emits?: Record; + readonly subscribes?: Record; + 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; + readonly source?: string; + readonly credentialPipeline?: string; +} + +export interface HiveCastKeyInfo { + readonly ok: true; + readonly cloud: string; + readonly space: string; + readonly key?: Record; + readonly principal?: Record; + readonly profile?: Record; +} + +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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +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, +): Promise> { + 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 { + 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 { + 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), + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 113cc0a..b967777 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a7f3ad..f68e3aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/prove-fresh-samples.mjs b/scripts/prove-fresh-samples.mjs new file mode 100644 index 0000000..9595d2f --- /dev/null +++ b/scripts/prove-fresh-samples.mjs @@ -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));