@love-moon/app-sdk 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * POST /api/conductor/bind → find-or-create the Conductor project for this
3
+ * app, then create a fresh task inside it. Returns `{ projectId, taskId }`
4
+ * for the browser to bootstrap the chat against.
5
+ *
6
+ * In a real app you'd probably persist projectId on the user record (it
7
+ * stays the same forever) and only mint a new task when the user clicks
8
+ * "start a new conversation". For the demo we mint a task on every bind.
9
+ */
10
+ import { NextResponse } from 'next/server';
11
+ import { bindDemoProject, getClient } from '@/lib/conductor';
12
+ import { isConductorAppError } from '@love-moon/app-sdk';
13
+
14
+ export const runtime = 'nodejs';
15
+
16
+ export async function POST() {
17
+ try {
18
+ const project = await bindDemoProject();
19
+ const client = await getClient();
20
+ const task = await client.tasks.create({
21
+ projectId: project.id,
22
+ title: 'Demo chat',
23
+ });
24
+ return NextResponse.json({
25
+ projectId: project.id,
26
+ taskId: task.id,
27
+ daemonHost: project.daemonHost,
28
+ });
29
+ } catch (err) {
30
+ return errorResponse(err);
31
+ }
32
+ }
33
+
34
+ function errorResponse(err: unknown) {
35
+ if (isConductorAppError(err)) {
36
+ return NextResponse.json(
37
+ { error: err.message, code: err.code },
38
+ { status: err.status ?? 500 },
39
+ );
40
+ }
41
+ return NextResponse.json(
42
+ { error: (err as Error)?.message ?? 'Internal error' },
43
+ { status: 500 },
44
+ );
45
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export const metadata = {
4
+ title: 'Conductor App SDK demo',
5
+ };
6
+
7
+ export default function RootLayout({ children }: { children: ReactNode }) {
8
+ return (
9
+ <html lang="en">
10
+ <body
11
+ style={{
12
+ margin: 0,
13
+ padding: 0,
14
+ fontFamily:
15
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
16
+ background: '#fafafa',
17
+ color: '#111',
18
+ minHeight: '100vh',
19
+ }}
20
+ >
21
+ {children}
22
+ </body>
23
+ </html>
24
+ );
25
+ }
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Demo page.
5
+ *
6
+ * Flow:
7
+ * 1. On mount, POST /api/conductor/bind → BFF binds the Conductor project
8
+ * and creates a fresh task. Returns `{ taskId }` for the widget to use.
9
+ * 2. Mount <ChatView> pointed at /api/conductor as the BFF base URL.
10
+ *
11
+ * Roughly 50 lines of business code for a real end-to-end chat — that's the
12
+ * whole pitch of @love-moon/app-sdk.
13
+ */
14
+
15
+ import { useEffect, useState } from 'react';
16
+ import { ChatView, createRestAdapter } from '@love-moon/app-sdk/react';
17
+ import '@love-moon/app-sdk/react/styles.css';
18
+
19
+ interface Bootstrap {
20
+ projectId: string;
21
+ taskId: string;
22
+ daemonHost: string | null;
23
+ }
24
+
25
+ const adapter = createRestAdapter({
26
+ baseUrl: '/api/conductor',
27
+ // No authToken — we trust the browser session and let the BFF authenticate
28
+ // upstream via its server-held Conductor token.
29
+ });
30
+
31
+ export default function Page() {
32
+ const [bootstrap, setBootstrap] = useState<Bootstrap | null>(null);
33
+ const [error, setError] = useState<string | null>(null);
34
+
35
+ useEffect(() => {
36
+ let cancelled = false;
37
+ (async () => {
38
+ try {
39
+ const res = await fetch('/api/conductor/bind', { method: 'POST' });
40
+ if (!res.ok) {
41
+ const detail = await res.json().catch(() => ({}));
42
+ throw new Error(detail.error ?? `bind failed (${res.status})`);
43
+ }
44
+ const data = (await res.json()) as Bootstrap;
45
+ if (!cancelled) setBootstrap(data);
46
+ } catch (e) {
47
+ if (!cancelled) setError((e as Error).message);
48
+ }
49
+ })();
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, []);
54
+
55
+ if (error) {
56
+ return (
57
+ <Frame>
58
+ <h1>Demo unavailable</h1>
59
+ <p style={{ color: '#b04020' }}>{error}</p>
60
+ <p>Check your <code>.env.local</code> against <code>.env.example</code>.</p>
61
+ </Frame>
62
+ );
63
+ }
64
+
65
+ if (!bootstrap) {
66
+ return (
67
+ <Frame>
68
+ <p>Binding project + creating task…</p>
69
+ </Frame>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <Frame>
75
+ <header
76
+ style={{
77
+ padding: '12px 16px',
78
+ borderBottom: '1px solid #eee',
79
+ fontSize: 13,
80
+ color: '#666',
81
+ }}
82
+ >
83
+ Task <code>{bootstrap.taskId}</code> on daemon{' '}
84
+ <code>{bootstrap.daemonHost ?? 'unknown'}</code>
85
+ </header>
86
+ <div style={{ flex: 1, minHeight: 0 }}>
87
+ <ChatView
88
+ taskId={bootstrap.taskId}
89
+ adapter={adapter}
90
+ onError={(e) => console.error('[demo] chat error', e)}
91
+ />
92
+ </div>
93
+ </Frame>
94
+ );
95
+ }
96
+
97
+ function Frame({ children }: { children: React.ReactNode }) {
98
+ return (
99
+ <div
100
+ style={{
101
+ display: 'flex',
102
+ flexDirection: 'column',
103
+ height: '100vh',
104
+ maxWidth: 800,
105
+ margin: '0 auto',
106
+ background: '#fff',
107
+ borderLeft: '1px solid #eee',
108
+ borderRight: '1px solid #eee',
109
+ }}
110
+ >
111
+ {children}
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Server-side singleton for the Conductor SDK client.
3
+ *
4
+ * Runs in Next.js Route Handlers (Node.js runtime). One AppClient is shared
5
+ * across all requests in the same Node process. The first call to `await
6
+ * getClient()` opens a /ws/app WebSocket; subsequent calls reuse it.
7
+ */
8
+ import { connect, type AppClient } from '@love-moon/app-sdk/server';
9
+
10
+ let cachedClient: AppClient | null = null;
11
+ let cachedClientPromise: Promise<AppClient> | null = null;
12
+
13
+ function readEnv(key: string): string {
14
+ const value = process.env[key];
15
+ if (!value) {
16
+ throw new Error(
17
+ `Missing env var ${key}. Copy .env.example to .env.local and fill it in.`,
18
+ );
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export async function getClient(): Promise<AppClient> {
24
+ if (cachedClient) return cachedClient;
25
+ if (cachedClientPromise) return cachedClientPromise;
26
+ // We cache the *promise* so concurrent callers share one connect; but if
27
+ // it rejects we clear the cache so the next caller retries from scratch
28
+ // rather than re-receiving the same rejection forever.
29
+ const promise = (async () => {
30
+ const client = await connect({
31
+ baseUrl: readEnv('CONDUCTOR_BASE_URL'),
32
+ bearerToken: readEnv('CONDUCTOR_TOKEN'),
33
+ onUnauthorized: () => {
34
+ // In a real app: page the on-call team, rotate the token, etc.
35
+ // For the demo we just log.
36
+ console.error('[demo] Conductor returned 401 — token invalid or revoked?');
37
+ },
38
+ });
39
+ cachedClient = client;
40
+ return client;
41
+ })();
42
+ cachedClientPromise = promise;
43
+ promise.catch(() => {
44
+ // Clear the cached promise on failure so the next call can retry.
45
+ if (cachedClientPromise === promise) {
46
+ cachedClientPromise = null;
47
+ }
48
+ });
49
+ return promise;
50
+ }
51
+
52
+ /** Idempotent project binding — returns the same project on repeat calls. */
53
+ export async function bindDemoProject() {
54
+ const client = await getClient();
55
+ return await client.projects.bind({
56
+ name: process.env.CONDUCTOR_APP_NAME ?? 'App SDK Demo',
57
+ daemonHost: readEnv('CONDUCTOR_DAEMON_HOST'),
58
+ workspacePath: readEnv('CONDUCTOR_WORKSPACE_PATH'),
59
+ });
60
+ }
@@ -0,0 +1,9 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: true,
4
+ // Transpile the workspace SDK so its dist/ ESM modules are picked up by
5
+ // Next's bundler without surprises.
6
+ transpilePackages: ['@love-moon/app-sdk'],
7
+ };
8
+
9
+ export default nextConfig;