@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.0
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.
- package/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/src/dev.mjs
CHANGED
|
@@ -1,459 +1,459 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { createServer } from 'node:http';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { TEMPLATES } from './create.mjs';
|
|
6
|
-
|
|
7
|
-
const BRIDGE_PACKAGE = '@bridgeline-digital/hawk-app-bridge';
|
|
8
|
-
export const DEFAULT_APP_URL = 'http://localhost:3100/';
|
|
9
|
-
export const DEFAULT_PORT = 3200;
|
|
10
|
-
const DEFAULT_HAWKSEARCH_DOMAIN = 'https://dev.hawksearch.net';
|
|
11
|
-
/** Tokens are reused until this close to expiry. */
|
|
12
|
-
const TOKEN_SKEW_MS = 60_000;
|
|
13
|
-
const MAX_PROXY_BODY = 10 * 1024 * 1024;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* `hawk-app dev`: a local stand-in for the HawkSearch host. Serves a page that embeds the app (its dev server) with the
|
|
17
|
-
* bridge's own `<hawk-app-frame>`, gets real app tokens from the platform with the publisher key
|
|
18
|
-
* (`POST /publish/apps/{slug}/dev-token`, any client), and proxies the app's platform calls (so no CORS setup is needed,
|
|
19
|
-
* on dev or prod). Nothing is written to disk; the HawkSearch API key, if any, comes from the environment.
|
|
20
|
-
*/
|
|
21
|
-
export async function devCommand({ client, manifest, flags = {}, projectDir = process.cwd(), env = process.env, log = console.log, signal, startDev = npmRunDev, waitForApp = waitForUrl }) {
|
|
22
|
-
const clientId = flags.client ?? env.HAWK_APPS_DEV_CLIENT;
|
|
23
|
-
if (!clientId || clientId === true) {
|
|
24
|
-
throw new Error('Usage: hawk-app dev --client <client id> [--engine <id>] [--url http://localhost:3100] [--port 3200] [--client-user] [--no-start]\n'
|
|
25
|
-
+ ' (or set HAWK_APPS_DEV_CLIENT in .env.local)');
|
|
26
|
-
}
|
|
27
|
-
const engineId = flags.engine ?? env.HAWK_APPS_DEV_ENGINE ?? null;
|
|
28
|
-
const appUrl = normalizeAppUrl(typeof flags.url === 'string' ? flags.url : defaultAppUrl(projectDir, manifest));
|
|
29
|
-
const port = Number(flags.port ?? DEFAULT_PORT);
|
|
30
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('--port must be a port number.');
|
|
31
|
-
const hawkSearch = env.HAWKSEARCH_API_KEY
|
|
32
|
-
? { domain: (env.HAWKSEARCH_DOMAIN || DEFAULT_HAWKSEARCH_DOMAIN).replace(/\/+$/, ''), apiKey: env.HAWKSEARCH_API_KEY, isGroupKey: env.HAWKSEARCH_GROUP_KEY === 'true' }
|
|
33
|
-
: null;
|
|
34
|
-
|
|
35
|
-
const hostScript = await bundleHost(projectDir);
|
|
36
|
-
const tokens = tokenBroker({ client, slug: manifest.slug, hawkSearch });
|
|
37
|
-
const settings = { slug: manifest.slug, name: manifest.name ?? manifest.slug, appUrl, clientId: String(clientId), engineId: engineId ? String(engineId) : null, internal: !flags['client-user'] };
|
|
38
|
-
|
|
39
|
-
// The app's dev server, unless it runs already (--url) or --no-start.
|
|
40
|
-
let child = null;
|
|
41
|
-
if (!flags.url && !flags['no-start']) {
|
|
42
|
-
log(`› Starting the app: npm run dev (in ${projectDir})`);
|
|
43
|
-
child = startDev({ cwd: projectDir });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const server = createHostServer({ settings, hostScript, tokens, platformBase: client.base, log });
|
|
47
|
-
await new Promise((resolve, reject) => {
|
|
48
|
-
server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new Error(`Port ${port} is in use: pick another with --port.`) : error));
|
|
49
|
-
server.listen(port, '127.0.0.1', resolve);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
log(`› Waiting for the app at ${appUrl} …`);
|
|
53
|
-
const ready = await waitForApp(appUrl, { signal });
|
|
54
|
-
if (!ready) log(`! ${appUrl} didn't answer yet; the page will load it when it does.`);
|
|
55
|
-
log('');
|
|
56
|
-
log(`✔ Local host: http://localhost:${port}/`);
|
|
57
|
-
log(` app ${settings.slug} from ${appUrl}, client ${settings.clientId}${settings.engineId ? `, site ${settings.engineId}` : ''}, as a ${settings.internal ? 'Bridgeline' : 'client'} user`);
|
|
58
|
-
log(` platform ${client.base} (real data)${hawkSearch ? `; HawkSearch ${hawkSearch.domain} with HAWKSEARCH_API_KEY` : '; no HawkSearch key (set HAWKSEARCH_API_KEY for the dashboard API)'}`);
|
|
59
|
-
log(' Change the client, site, user, dates and theme in the page. Ctrl+C to stop.');
|
|
60
|
-
|
|
61
|
-
await new Promise((resolve) => {
|
|
62
|
-
const stop = () => resolve();
|
|
63
|
-
signal?.addEventListener('abort', stop, { once: true });
|
|
64
|
-
process.once('SIGINT', stop);
|
|
65
|
-
});
|
|
66
|
-
server.close();
|
|
67
|
-
child?.kill();
|
|
68
|
-
return 0;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Where the app's dev server serves it: the template's URL (Next.js serves the app under /<slug>/ only), else
|
|
73
|
-
* /<slug>/ for a Next.js app, else the root (Vite redirects it to its base).
|
|
74
|
-
*/
|
|
75
|
-
export function defaultAppUrl(projectDir, manifest) {
|
|
76
|
-
const template = TEMPLATES[manifest.template];
|
|
77
|
-
if (template) return template.devUrl(manifest.slug);
|
|
78
|
-
try {
|
|
79
|
-
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
|
80
|
-
if ({ ...pkg.dependencies, ...pkg.devDependencies }.next) return `http://localhost:3100/${manifest.slug}/`;
|
|
81
|
-
} catch {
|
|
82
|
-
// No package.json: the root.
|
|
83
|
-
}
|
|
84
|
-
return DEFAULT_APP_URL;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** `http://localhost:3100` → `http://localhost:3100/`; only http(s) URLs. */
|
|
88
|
-
export function normalizeAppUrl(value) {
|
|
89
|
-
let url;
|
|
90
|
-
try {
|
|
91
|
-
url = new URL(value);
|
|
92
|
-
} catch {
|
|
93
|
-
throw new Error(`--url must be the app's dev server URL, e.g. ${DEFAULT_APP_URL}`);
|
|
94
|
-
}
|
|
95
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('--url must be an http(s) URL.');
|
|
96
|
-
return url.toString();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** App tokens from the platform, cached per client, site and user kind until a minute before they expire. */
|
|
100
|
-
export function tokenBroker({ client, slug, hawkSearch, now = () => Date.now() }) {
|
|
101
|
-
const cache = new Map();
|
|
102
|
-
return async ({ clientId, engineId, internal, forceRefresh = false }) => {
|
|
103
|
-
const key = `${clientId}|${engineId ?? ''}|${internal}`;
|
|
104
|
-
const cached = cache.get(key);
|
|
105
|
-
if (!forceRefresh && cached && Date.parse(cached.expiresAt) - TOKEN_SKEW_MS > now()) return cached;
|
|
106
|
-
const issued = await client.devToken(slug, { clientId, engineId: engineId || null, internal, ...(hawkSearch ? { hawkSearch } : {}) });
|
|
107
|
-
cache.set(key, issued);
|
|
108
|
-
return issued;
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** The bridge's host side (`<hawk-app-frame>`) from the app's own bridge, bundled for the browser. */
|
|
113
|
-
export async function bundleHost(projectDir) {
|
|
114
|
-
const esbuild = await import('esbuild');
|
|
115
|
-
try {
|
|
116
|
-
// esbuild resolves the package the way the browser build would (its "import" export).
|
|
117
|
-
const result = await esbuild.build({
|
|
118
|
-
stdin: { contents: `export { defineHawkAppFrame } from '${BRIDGE_PACKAGE}/host';`, resolveDir: projectDir, loader: 'js' },
|
|
119
|
-
bundle: true,
|
|
120
|
-
format: 'esm',
|
|
121
|
-
platform: 'browser',
|
|
122
|
-
target: 'es2020',
|
|
123
|
-
write: false,
|
|
124
|
-
logLevel: 'silent',
|
|
125
|
-
});
|
|
126
|
-
return result.outputFiles[0].text;
|
|
127
|
-
} catch {
|
|
128
|
-
throw new Error(`hawk-app dev needs ${BRIDGE_PACKAGE} (0.4.0 or later) in the app: run \`npm install ${BRIDGE_PACKAGE}\`.`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* The local host: `/` (the page), `/host.js` (the bridge's host side), `POST /token` (from the page only), `/api/*`
|
|
134
|
-
* (the platform, for the app; CORS for the app's origin only) and `POST /ai-stream` (the AI stream, see `proxyAiStream`).
|
|
135
|
-
*/
|
|
136
|
-
export function createHostServer({ settings, hostScript, tokens, platformBase, log = console.log, fetchImpl = fetch }) {
|
|
137
|
-
const appOrigin = new URL(settings.appUrl).origin;
|
|
138
|
-
// The real AI stream URL from the platform's last `GET ai` answer, which the app is told is `/ai-stream` here.
|
|
139
|
-
const ai = { streamUrl: null };
|
|
140
|
-
const server = createServer(async (req, res) => {
|
|
141
|
-
try {
|
|
142
|
-
const port = server.address().port;
|
|
143
|
-
// Only requests to this machine by name (a DNS-rebound site has its own host name).
|
|
144
|
-
if (![`localhost:${port}`, `127.0.0.1:${port}`].includes(req.headers.host ?? '')) return send(res, 421, 'text/plain', 'Misdirected request');
|
|
145
|
-
const hostOrigin = `http://${req.headers.host}`;
|
|
146
|
-
const url = new URL(req.url ?? '/', hostOrigin);
|
|
147
|
-
if (req.method === 'GET' && url.pathname === '/') {
|
|
148
|
-
return send(res, 200, 'text/html; charset=utf-8', hostPage(settings));
|
|
149
|
-
}
|
|
150
|
-
if (req.method === 'GET' && url.pathname === '/host.js') {
|
|
151
|
-
return send(res, 200, 'text/javascript; charset=utf-8', hostScript);
|
|
152
|
-
}
|
|
153
|
-
if (url.pathname === '/token') {
|
|
154
|
-
// Only the local page may ask for tokens (a page on another site can't, even from this machine).
|
|
155
|
-
if (req.method !== 'POST' || !sameOrigin(req, hostOrigin)) return send(res, 403, 'application/json', JSON.stringify({ error: 'forbidden' }));
|
|
156
|
-
const body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}');
|
|
157
|
-
const issued = await tokens({ clientId: String(body.clientId ?? ''), engineId: body.engineId ? String(body.engineId) : null, internal: body.internal !== false, forceRefresh: !!body.forceRefresh });
|
|
158
|
-
return send(res, 200, 'application/json', JSON.stringify(issued));
|
|
159
|
-
}
|
|
160
|
-
if (url.pathname.startsWith('/api/')) {
|
|
161
|
-
return proxy(req, res, url, { platformBase, appOrigin, hostOrigin, ai, fetchImpl });
|
|
162
|
-
}
|
|
163
|
-
if (url.pathname === '/ai-stream') {
|
|
164
|
-
return proxyAiStream(req, res, { appOrigin, ai, fetchImpl });
|
|
165
|
-
}
|
|
166
|
-
send(res, 404, 'text/plain', 'Not found');
|
|
167
|
-
} catch (error) {
|
|
168
|
-
log(`! ${error.message.split('\n')[0]}`);
|
|
169
|
-
send(res, error.status ?? 500, 'application/json', JSON.stringify({ error: error.message, ...(error.data?.code ? { code: error.data.code } : {}) }));
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
return server;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function sameOrigin(req, hostOrigin) {
|
|
176
|
-
return req.headers.origin === hostOrigin;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function corsHeaders(appOrigin, methods) {
|
|
180
|
-
return {
|
|
181
|
-
'Access-Control-Allow-Origin': appOrigin,
|
|
182
|
-
'Access-Control-Allow-Methods': methods,
|
|
183
|
-
'Access-Control-Allow-Headers': 'Authorization, Accept, Content-Type',
|
|
184
|
-
'Access-Control-Expose-Headers': 'Retry-After',
|
|
185
|
-
'Access-Control-Max-Age': '600',
|
|
186
|
-
Vary: 'Origin',
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Only the headers the platform needs: Authorization, Content-Type and Accept. */
|
|
191
|
-
function forwardHeaders(req, accept) {
|
|
192
|
-
const headers = { Accept: req.headers.accept ?? accept };
|
|
193
|
-
if (req.headers.authorization) headers.Authorization = req.headers.authorization;
|
|
194
|
-
if (req.headers['content-type']) headers['Content-Type'] = req.headers['content-type'];
|
|
195
|
-
return headers;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The app's platform calls, sent on with only the headers the platform needs. The answer to `GET ai` has its
|
|
200
|
-
* `streamUrl` (a Lambda function URL, whose CORS doesn't allow this page) replaced with this host's `/ai-stream`.
|
|
201
|
-
*/
|
|
202
|
-
async function proxy(req, res, url, { platformBase, appOrigin, hostOrigin, ai, fetchImpl }) {
|
|
203
|
-
const cors = corsHeaders(appOrigin, 'GET, POST, PUT, DELETE');
|
|
204
|
-
if (req.method === 'OPTIONS') {
|
|
205
|
-
res.writeHead(204, cors);
|
|
206
|
-
return res.end();
|
|
207
|
-
}
|
|
208
|
-
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req, MAX_PROXY_BODY);
|
|
209
|
-
const target = `${platformBase}${url.pathname.slice('/api'.length)}${url.search}`;
|
|
210
|
-
const response = await fetchImpl(target, { method: req.method, headers: forwardHeaders(req, 'application/json'), body });
|
|
211
|
-
const out = { ...cors, 'Content-Type': response.headers.get('content-type') ?? 'application/json', 'Cache-Control': 'no-store' };
|
|
212
|
-
if (response.headers.has('retry-after')) out['Retry-After'] = response.headers.get('retry-after');
|
|
213
|
-
let payload = Buffer.from(await response.arrayBuffer());
|
|
214
|
-
if (req.method === 'GET' && response.ok && /^\/api\/v\d+\/ai$/.test(url.pathname)) {
|
|
215
|
-
payload = rewriteAiDiscovery(payload, `${hostOrigin}/ai-stream`, ai);
|
|
216
|
-
}
|
|
217
|
-
res.writeHead(response.status, out);
|
|
218
|
-
res.end(payload);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** A real AI stream URL: https on a Lambda function URL host (`*.on.aws`), path `/v1/ai/messages`, nothing else. */
|
|
222
|
-
export function isAiStreamUrl(value) {
|
|
223
|
-
try {
|
|
224
|
-
const url = new URL(value);
|
|
225
|
-
return url.protocol === 'https:' && url.hostname.endsWith('.on.aws') && !url.port && !url.username && !url.password
|
|
226
|
-
&& url.pathname === '/v1/ai/messages' && !url.search && !url.hash;
|
|
227
|
-
} catch {
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Remembers the `streamUrl` of a `GET ai` answer and points the app at `localStream` instead. Any other stream URL
|
|
234
|
-
* (e.g. a local platform's own) is left as it is and never proxied.
|
|
235
|
-
*/
|
|
236
|
-
function rewriteAiDiscovery(payload, localStream, ai) {
|
|
237
|
-
let body;
|
|
238
|
-
try {
|
|
239
|
-
body = JSON.parse(payload.toString('utf8'));
|
|
240
|
-
} catch {
|
|
241
|
-
return payload;
|
|
242
|
-
}
|
|
243
|
-
if (!body || !isAiStreamUrl(body.streamUrl)) return payload;
|
|
244
|
-
ai.streamUrl = body.streamUrl;
|
|
245
|
-
return Buffer.from(JSON.stringify({ ...body, streamUrl: localStream }));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* `POST /ai-stream`: the app's AI request, sent on to the stream URL remembered from `GET ai`, with the event stream
|
|
250
|
-
* piped back as it arrives (not buffered). Stops the upstream request when the app goes away.
|
|
251
|
-
*/
|
|
252
|
-
async function proxyAiStream(req, res, { appOrigin, ai, fetchImpl }) {
|
|
253
|
-
const cors = corsHeaders(appOrigin, 'POST');
|
|
254
|
-
if (req.method === 'OPTIONS') {
|
|
255
|
-
res.writeHead(204, cors);
|
|
256
|
-
return res.end();
|
|
257
|
-
}
|
|
258
|
-
if (req.method !== 'POST') {
|
|
259
|
-
res.writeHead(405, { ...cors, Allow: 'POST', 'Content-Type': 'text/plain' });
|
|
260
|
-
return res.end('Method not allowed');
|
|
261
|
-
}
|
|
262
|
-
if (!ai.streamUrl) {
|
|
263
|
-
res.writeHead(503, { ...cors, 'Content-Type': 'application/problem+json', 'Cache-Control': 'no-store' });
|
|
264
|
-
return res.end(JSON.stringify({ status: 503, code: 'ai_not_configured', detail: 'No AI stream URL yet: the app must call GET ai first.' }));
|
|
265
|
-
}
|
|
266
|
-
const body = await readBody(req, MAX_PROXY_BODY);
|
|
267
|
-
const upstream = new AbortController();
|
|
268
|
-
res.on('close', () => {
|
|
269
|
-
if (!res.writableFinished) upstream.abort();
|
|
270
|
-
});
|
|
271
|
-
const response = await fetchImpl(ai.streamUrl, { method: 'POST', headers: forwardHeaders(req, 'text/event-stream'), body, signal: upstream.signal });
|
|
272
|
-
const out = { ...cors, 'Content-Type': response.headers.get('content-type') ?? 'text/event-stream', 'Cache-Control': 'no-store' };
|
|
273
|
-
if (response.headers.has('retry-after')) out['Retry-After'] = response.headers.get('retry-after');
|
|
274
|
-
res.writeHead(response.status, out);
|
|
275
|
-
res.flushHeaders();
|
|
276
|
-
try {
|
|
277
|
-
for await (const chunk of response.body ?? []) {
|
|
278
|
-
res.write(chunk);
|
|
279
|
-
}
|
|
280
|
-
} catch {
|
|
281
|
-
// The app went away (or the stream broke): nothing more to send.
|
|
282
|
-
}
|
|
283
|
-
res.end();
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function readBody(req, limit) {
|
|
287
|
-
return new Promise((resolve, reject) => {
|
|
288
|
-
const chunks = [];
|
|
289
|
-
let size = 0;
|
|
290
|
-
req.on('data', (chunk) => {
|
|
291
|
-
size += chunk.length;
|
|
292
|
-
if (size > limit) {
|
|
293
|
-
reject(Object.assign(new Error('Request body too large.'), { status: 413 }));
|
|
294
|
-
req.destroy();
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
chunks.push(chunk);
|
|
298
|
-
});
|
|
299
|
-
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
300
|
-
req.on('error', reject);
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function send(res, status, type, body) {
|
|
305
|
-
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
306
|
-
res.end(body);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** Runs the app's `npm run dev` (its own output stays visible). */
|
|
310
|
-
export function npmRunDev({ cwd }) {
|
|
311
|
-
return spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'dev'], { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** Polls the URL until it answers (any status), up to `timeoutMs`. */
|
|
315
|
-
export async function waitForUrl(url, { timeoutMs = 60_000, intervalMs = 500, signal, fetchImpl = fetch } = {}) {
|
|
316
|
-
const deadline = Date.now() + timeoutMs;
|
|
317
|
-
while (Date.now() < deadline && !signal?.aborted) {
|
|
318
|
-
try {
|
|
319
|
-
await fetchImpl(url, { method: 'GET', signal: AbortSignal.timeout(2000) });
|
|
320
|
-
return true;
|
|
321
|
-
} catch {
|
|
322
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return false;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
329
|
-
|
|
330
|
-
/** The host page: a toolbar (client, site, user, dates, theme), the app, and panels for toasts, events and the token. */
|
|
331
|
-
export function hostPage(settings) {
|
|
332
|
-
const today = new Date();
|
|
333
|
-
const from = new Date(today.getTime() - 29 * 86_400_000);
|
|
334
|
-
const initial = { ...settings, from: from.toISOString().slice(0, 10), to: today.toISOString().slice(0, 10), theme: 'light' };
|
|
335
|
-
return `<!doctype html>
|
|
336
|
-
<html lang="en">
|
|
337
|
-
<head>
|
|
338
|
-
<meta charset="utf-8">
|
|
339
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
340
|
-
<title>${escapeHtml(settings.name)} · hawk-app dev</title>
|
|
341
|
-
<style>
|
|
342
|
-
:root { --bg: #f6f7f9; --panel: #fff; --text: #1d2330; --muted: #667085; --border: #d0d5dd; --accent: #f26b1d; }
|
|
343
|
-
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
|
344
|
-
body.dark { --bg: #14171c; --panel: #1d2128; --text: #e6e8eb; --muted: #98a2b3; --border: #343a46; }
|
|
345
|
-
header { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
|
346
|
-
header h1 { font-size: 16px; margin: 0 16px 6px 0; }
|
|
347
|
-
header h1 small { color: var(--muted); font-weight: normal; }
|
|
348
|
-
label { display: flex; flex-direction: column; font-size: 12px; color: var(--muted); gap: 2px; }
|
|
349
|
-
input, select, button { font: inherit; padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--panel); color: var(--text); }
|
|
350
|
-
button { cursor: pointer; } button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
351
|
-
main { display: grid; grid-template-columns: 1fr 340px; gap: 12px; padding: 12px 16px; }
|
|
352
|
-
#title { font-size: 18px; margin: 0 0 8px; }
|
|
353
|
-
hawk-app-frame { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; min-height: 400px; }
|
|
354
|
-
aside section { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; margin-bottom: 12px; }
|
|
355
|
-
aside h2 { font-size: 13px; margin: 0 0 6px; }
|
|
356
|
-
aside pre { white-space: pre-wrap; word-break: break-all; font-size: 11px; margin: 0; max-height: 260px; overflow: auto; }
|
|
357
|
-
#events div { font-size: 12px; border-top: 1px solid var(--border); padding: 3px 0; }
|
|
358
|
-
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
359
|
-
.toast { padding: 8px 12px; border-radius: 4px; color: #fff; background: #344054; box-shadow: 0 2px 6px rgba(0,0,0,.2); }
|
|
360
|
-
.toast.success { background: #12b76a; } .toast.warning { background: #f79009; } .toast.error { background: #f04438; }
|
|
361
|
-
@media (max-width: 900px) { main { grid-template-columns: 1fr; } }
|
|
362
|
-
</style>
|
|
363
|
-
</head>
|
|
364
|
-
<body>
|
|
365
|
-
<header>
|
|
366
|
-
<h1>${escapeHtml(settings.name)} <small>hawk-app dev</small></h1>
|
|
367
|
-
<label>Client <input id="clientId" size="10"></label>
|
|
368
|
-
<label>Site (engine) <input id="engineId" size="8" placeholder="none"></label>
|
|
369
|
-
<label>User <select id="internal"><option value="true">Bridgeline user</option><option value="false">Client user</option></select></label>
|
|
370
|
-
<label>From <input id="from" type="date"></label>
|
|
371
|
-
<label>To <input id="to" type="date"></label>
|
|
372
|
-
<label>Theme <select id="theme"><option>light</option><option>dark</option></select></label>
|
|
373
|
-
<button class="primary" id="apply">Apply</button>
|
|
374
|
-
<button id="reload">Reload app</button>
|
|
375
|
-
</header>
|
|
376
|
-
<main>
|
|
377
|
-
<div>
|
|
378
|
-
<p id="title"></p>
|
|
379
|
-
<hawk-app-frame id="frame" title="${escapeHtml(settings.name)}"></hawk-app-frame>
|
|
380
|
-
</div>
|
|
381
|
-
<aside>
|
|
382
|
-
<section><h2>Token</h2><pre id="token">—</pre></section>
|
|
383
|
-
<section><h2>Messages from the app</h2><div id="events"></div></section>
|
|
384
|
-
</aside>
|
|
385
|
-
</main>
|
|
386
|
-
<div id="toasts"></div>
|
|
387
|
-
<script type="module">
|
|
388
|
-
import { defineHawkAppFrame } from '/host.js';
|
|
389
|
-
defineHawkAppFrame();
|
|
390
|
-
const initial = ${JSON.stringify(initial).replace(/</g, '\\u003c')};
|
|
391
|
-
const $ = (id) => document.getElementById(id);
|
|
392
|
-
const frame = $('frame');
|
|
393
|
-
let state = { ...initial };
|
|
394
|
-
for (const key of ['clientId', 'engineId', 'from', 'to', 'theme']) $(key).value = state[key] ?? '';
|
|
395
|
-
$('internal').value = String(state.internal);
|
|
396
|
-
|
|
397
|
-
const event = (text) => { const row = document.createElement('div'); row.textContent = new Date().toLocaleTimeString() + ' ' + text; $('events').prepend(row); };
|
|
398
|
-
const claims = (token) => JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
399
|
-
|
|
400
|
-
function context(token) {
|
|
401
|
-
const c = claims(token);
|
|
402
|
-
return {
|
|
403
|
-
protocolVersion: 1,
|
|
404
|
-
app: { slug: state.slug },
|
|
405
|
-
tenant: { clientId: state.clientId },
|
|
406
|
-
user: { id: c.sub, roles: [] },
|
|
407
|
-
engine: state.engineId ? { id: state.engineId } : null,
|
|
408
|
-
site: state.engineId ? { id: state.engineId } : null,
|
|
409
|
-
dateRange: { from: state.from, to: state.to },
|
|
410
|
-
locale: navigator.language || 'en-US',
|
|
411
|
-
theme: { mode: state.theme, tokens: {} },
|
|
412
|
-
apiBaseUrl: location.origin + '/api/v1/',
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
async function token(forceRefresh) {
|
|
417
|
-
const res = await fetch('/token', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
418
|
-
body: JSON.stringify({ clientId: state.clientId, engineId: state.engineId || null, internal: state.internal, forceRefresh }) });
|
|
419
|
-
const body = await res.json();
|
|
420
|
-
if (!res.ok) { event('token: ' + (body.error || res.status)); throw new Error(body.error || 'No token'); }
|
|
421
|
-
const c = claims(body.token);
|
|
422
|
-
$('token').textContent = JSON.stringify({ expiresAt: body.expiresAt, version: body.version, user: c.sub, client: c.client_id, engine: c.engine ?? null,
|
|
423
|
-
internal: c.internal, scopes: c.scope, hawksearch: body.hawkSearch }, null, 2);
|
|
424
|
-
return body;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
frame.tokenProvider = async ({ forceRefresh }) => { const t = await token(forceRefresh); return { token: t.token, expiresAt: t.expiresAt }; };
|
|
428
|
-
frame.addEventListener('hawk-ready', () => event('ready'));
|
|
429
|
-
frame.addEventListener('hawk-title', (e) => { $('title').textContent = e.detail.title; document.title = e.detail.title + ' · hawk-app dev'; event('setTitle: ' + e.detail.title); });
|
|
430
|
-
frame.addEventListener('hawk-navigate', (e) => event('navigate: ' + JSON.stringify(e.detail)));
|
|
431
|
-
frame.addEventListener('hawk-toast', (e) => {
|
|
432
|
-
const t = document.createElement('div'); t.className = 'toast ' + (e.detail.variant || 'info'); t.textContent = e.detail.message;
|
|
433
|
-
$('toasts').append(t); setTimeout(() => t.remove(), 5000); event('toast (' + (e.detail.variant || 'info') + '): ' + e.detail.message);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
async function load(reloadApp) {
|
|
437
|
-
document.body.classList.toggle('dark', state.theme === 'dark');
|
|
438
|
-
const t = await token(false).catch(() => null);
|
|
439
|
-
if (!t) return;
|
|
440
|
-
frame.context = context(t.token);
|
|
441
|
-
if (reloadApp) { frame.removeAttribute('src'); frame.setAttribute('src', initial.appUrl); }
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
$('apply').addEventListener('click', () => {
|
|
445
|
-
const before = state;
|
|
446
|
-
state = { ...state, clientId: $('clientId').value.trim(), engineId: $('engineId').value.trim(), internal: $('internal').value === 'true',
|
|
447
|
-
from: $('from').value, to: $('to').value, theme: $('theme').value };
|
|
448
|
-
// A new client, site or user needs a new token: reload the app; dates and theme are context changes.
|
|
449
|
-
const identity = (s) => s.clientId + '|' + s.engineId + '|' + s.internal;
|
|
450
|
-
load(identity(before) !== identity(state));
|
|
451
|
-
event('context: ' + state.clientId + (state.engineId ? ' / site ' + state.engineId : '') + ' · ' + state.from + '..' + state.to + ' · ' + state.theme);
|
|
452
|
-
});
|
|
453
|
-
$('reload').addEventListener('click', () => load(true));
|
|
454
|
-
load(true);
|
|
455
|
-
</script>
|
|
456
|
-
</body>
|
|
457
|
-
</html>
|
|
458
|
-
`;
|
|
459
|
-
}
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { TEMPLATES } from './create.mjs';
|
|
6
|
+
|
|
7
|
+
const BRIDGE_PACKAGE = '@bridgeline-digital/hawk-app-bridge';
|
|
8
|
+
export const DEFAULT_APP_URL = 'http://localhost:3100/';
|
|
9
|
+
export const DEFAULT_PORT = 3200;
|
|
10
|
+
const DEFAULT_HAWKSEARCH_DOMAIN = 'https://dev.hawksearch.net';
|
|
11
|
+
/** Tokens are reused until this close to expiry. */
|
|
12
|
+
const TOKEN_SKEW_MS = 60_000;
|
|
13
|
+
const MAX_PROXY_BODY = 10 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `hawk-app dev`: a local stand-in for the HawkSearch host. Serves a page that embeds the app (its dev server) with the
|
|
17
|
+
* bridge's own `<hawk-app-frame>`, gets real app tokens from the platform with the publisher key
|
|
18
|
+
* (`POST /publish/apps/{slug}/dev-token`, any client), and proxies the app's platform calls (so no CORS setup is needed,
|
|
19
|
+
* on dev or prod). Nothing is written to disk; the HawkSearch API key, if any, comes from the environment.
|
|
20
|
+
*/
|
|
21
|
+
export async function devCommand({ client, manifest, flags = {}, projectDir = process.cwd(), env = process.env, log = console.log, signal, startDev = npmRunDev, waitForApp = waitForUrl }) {
|
|
22
|
+
const clientId = flags.client ?? env.HAWK_APPS_DEV_CLIENT;
|
|
23
|
+
if (!clientId || clientId === true) {
|
|
24
|
+
throw new Error('Usage: hawk-app dev --client <client id> [--engine <id>] [--url http://localhost:3100] [--port 3200] [--client-user] [--no-start]\n'
|
|
25
|
+
+ ' (or set HAWK_APPS_DEV_CLIENT in .env.local)');
|
|
26
|
+
}
|
|
27
|
+
const engineId = flags.engine ?? env.HAWK_APPS_DEV_ENGINE ?? null;
|
|
28
|
+
const appUrl = normalizeAppUrl(typeof flags.url === 'string' ? flags.url : defaultAppUrl(projectDir, manifest));
|
|
29
|
+
const port = Number(flags.port ?? DEFAULT_PORT);
|
|
30
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('--port must be a port number.');
|
|
31
|
+
const hawkSearch = env.HAWKSEARCH_API_KEY
|
|
32
|
+
? { domain: (env.HAWKSEARCH_DOMAIN || DEFAULT_HAWKSEARCH_DOMAIN).replace(/\/+$/, ''), apiKey: env.HAWKSEARCH_API_KEY, isGroupKey: env.HAWKSEARCH_GROUP_KEY === 'true' }
|
|
33
|
+
: null;
|
|
34
|
+
|
|
35
|
+
const hostScript = await bundleHost(projectDir);
|
|
36
|
+
const tokens = tokenBroker({ client, slug: manifest.slug, hawkSearch });
|
|
37
|
+
const settings = { slug: manifest.slug, name: manifest.name ?? manifest.slug, appUrl, clientId: String(clientId), engineId: engineId ? String(engineId) : null, internal: !flags['client-user'] };
|
|
38
|
+
|
|
39
|
+
// The app's dev server, unless it runs already (--url) or --no-start.
|
|
40
|
+
let child = null;
|
|
41
|
+
if (!flags.url && !flags['no-start']) {
|
|
42
|
+
log(`› Starting the app: npm run dev (in ${projectDir})`);
|
|
43
|
+
child = startDev({ cwd: projectDir });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const server = createHostServer({ settings, hostScript, tokens, platformBase: client.base, log });
|
|
47
|
+
await new Promise((resolve, reject) => {
|
|
48
|
+
server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new Error(`Port ${port} is in use: pick another with --port.`) : error));
|
|
49
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
log(`› Waiting for the app at ${appUrl} …`);
|
|
53
|
+
const ready = await waitForApp(appUrl, { signal });
|
|
54
|
+
if (!ready) log(`! ${appUrl} didn't answer yet; the page will load it when it does.`);
|
|
55
|
+
log('');
|
|
56
|
+
log(`✔ Local host: http://localhost:${port}/`);
|
|
57
|
+
log(` app ${settings.slug} from ${appUrl}, client ${settings.clientId}${settings.engineId ? `, site ${settings.engineId}` : ''}, as a ${settings.internal ? 'Bridgeline' : 'client'} user`);
|
|
58
|
+
log(` platform ${client.base} (real data)${hawkSearch ? `; HawkSearch ${hawkSearch.domain} with HAWKSEARCH_API_KEY` : '; no HawkSearch key (set HAWKSEARCH_API_KEY for the dashboard API)'}`);
|
|
59
|
+
log(' Change the client, site, user, dates and theme in the page. Ctrl+C to stop.');
|
|
60
|
+
|
|
61
|
+
await new Promise((resolve) => {
|
|
62
|
+
const stop = () => resolve();
|
|
63
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
64
|
+
process.once('SIGINT', stop);
|
|
65
|
+
});
|
|
66
|
+
server.close();
|
|
67
|
+
child?.kill();
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Where the app's dev server serves it: the template's URL (Next.js serves the app under /<slug>/ only), else
|
|
73
|
+
* /<slug>/ for a Next.js app, else the root (Vite redirects it to its base).
|
|
74
|
+
*/
|
|
75
|
+
export function defaultAppUrl(projectDir, manifest) {
|
|
76
|
+
const template = TEMPLATES[manifest.template];
|
|
77
|
+
if (template) return template.devUrl(manifest.slug);
|
|
78
|
+
try {
|
|
79
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
|
80
|
+
if ({ ...pkg.dependencies, ...pkg.devDependencies }.next) return `http://localhost:3100/${manifest.slug}/`;
|
|
81
|
+
} catch {
|
|
82
|
+
// No package.json: the root.
|
|
83
|
+
}
|
|
84
|
+
return DEFAULT_APP_URL;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `http://localhost:3100` → `http://localhost:3100/`; only http(s) URLs. */
|
|
88
|
+
export function normalizeAppUrl(value) {
|
|
89
|
+
let url;
|
|
90
|
+
try {
|
|
91
|
+
url = new URL(value);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`--url must be the app's dev server URL, e.g. ${DEFAULT_APP_URL}`);
|
|
94
|
+
}
|
|
95
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('--url must be an http(s) URL.');
|
|
96
|
+
return url.toString();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** App tokens from the platform, cached per client, site and user kind until a minute before they expire. */
|
|
100
|
+
export function tokenBroker({ client, slug, hawkSearch, now = () => Date.now() }) {
|
|
101
|
+
const cache = new Map();
|
|
102
|
+
return async ({ clientId, engineId, internal, forceRefresh = false }) => {
|
|
103
|
+
const key = `${clientId}|${engineId ?? ''}|${internal}`;
|
|
104
|
+
const cached = cache.get(key);
|
|
105
|
+
if (!forceRefresh && cached && Date.parse(cached.expiresAt) - TOKEN_SKEW_MS > now()) return cached;
|
|
106
|
+
const issued = await client.devToken(slug, { clientId, engineId: engineId || null, internal, ...(hawkSearch ? { hawkSearch } : {}) });
|
|
107
|
+
cache.set(key, issued);
|
|
108
|
+
return issued;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The bridge's host side (`<hawk-app-frame>`) from the app's own bridge, bundled for the browser. */
|
|
113
|
+
export async function bundleHost(projectDir) {
|
|
114
|
+
const esbuild = await import('esbuild');
|
|
115
|
+
try {
|
|
116
|
+
// esbuild resolves the package the way the browser build would (its "import" export).
|
|
117
|
+
const result = await esbuild.build({
|
|
118
|
+
stdin: { contents: `export { defineHawkAppFrame } from '${BRIDGE_PACKAGE}/host';`, resolveDir: projectDir, loader: 'js' },
|
|
119
|
+
bundle: true,
|
|
120
|
+
format: 'esm',
|
|
121
|
+
platform: 'browser',
|
|
122
|
+
target: 'es2020',
|
|
123
|
+
write: false,
|
|
124
|
+
logLevel: 'silent',
|
|
125
|
+
});
|
|
126
|
+
return result.outputFiles[0].text;
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(`hawk-app dev needs ${BRIDGE_PACKAGE} (0.4.0 or later) in the app: run \`npm install ${BRIDGE_PACKAGE}\`.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The local host: `/` (the page), `/host.js` (the bridge's host side), `POST /token` (from the page only), `/api/*`
|
|
134
|
+
* (the platform, for the app; CORS for the app's origin only) and `POST /ai-stream` (the AI stream, see `proxyAiStream`).
|
|
135
|
+
*/
|
|
136
|
+
export function createHostServer({ settings, hostScript, tokens, platformBase, log = console.log, fetchImpl = fetch }) {
|
|
137
|
+
const appOrigin = new URL(settings.appUrl).origin;
|
|
138
|
+
// The real AI stream URL from the platform's last `GET ai` answer, which the app is told is `/ai-stream` here.
|
|
139
|
+
const ai = { streamUrl: null };
|
|
140
|
+
const server = createServer(async (req, res) => {
|
|
141
|
+
try {
|
|
142
|
+
const port = server.address().port;
|
|
143
|
+
// Only requests to this machine by name (a DNS-rebound site has its own host name).
|
|
144
|
+
if (![`localhost:${port}`, `127.0.0.1:${port}`].includes(req.headers.host ?? '')) return send(res, 421, 'text/plain', 'Misdirected request');
|
|
145
|
+
const hostOrigin = `http://${req.headers.host}`;
|
|
146
|
+
const url = new URL(req.url ?? '/', hostOrigin);
|
|
147
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
148
|
+
return send(res, 200, 'text/html; charset=utf-8', hostPage(settings));
|
|
149
|
+
}
|
|
150
|
+
if (req.method === 'GET' && url.pathname === '/host.js') {
|
|
151
|
+
return send(res, 200, 'text/javascript; charset=utf-8', hostScript);
|
|
152
|
+
}
|
|
153
|
+
if (url.pathname === '/token') {
|
|
154
|
+
// Only the local page may ask for tokens (a page on another site can't, even from this machine).
|
|
155
|
+
if (req.method !== 'POST' || !sameOrigin(req, hostOrigin)) return send(res, 403, 'application/json', JSON.stringify({ error: 'forbidden' }));
|
|
156
|
+
const body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}');
|
|
157
|
+
const issued = await tokens({ clientId: String(body.clientId ?? ''), engineId: body.engineId ? String(body.engineId) : null, internal: body.internal !== false, forceRefresh: !!body.forceRefresh });
|
|
158
|
+
return send(res, 200, 'application/json', JSON.stringify(issued));
|
|
159
|
+
}
|
|
160
|
+
if (url.pathname.startsWith('/api/')) {
|
|
161
|
+
return proxy(req, res, url, { platformBase, appOrigin, hostOrigin, ai, fetchImpl });
|
|
162
|
+
}
|
|
163
|
+
if (url.pathname === '/ai-stream') {
|
|
164
|
+
return proxyAiStream(req, res, { appOrigin, ai, fetchImpl });
|
|
165
|
+
}
|
|
166
|
+
send(res, 404, 'text/plain', 'Not found');
|
|
167
|
+
} catch (error) {
|
|
168
|
+
log(`! ${error.message.split('\n')[0]}`);
|
|
169
|
+
send(res, error.status ?? 500, 'application/json', JSON.stringify({ error: error.message, ...(error.data?.code ? { code: error.data.code } : {}) }));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
return server;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sameOrigin(req, hostOrigin) {
|
|
176
|
+
return req.headers.origin === hostOrigin;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function corsHeaders(appOrigin, methods) {
|
|
180
|
+
return {
|
|
181
|
+
'Access-Control-Allow-Origin': appOrigin,
|
|
182
|
+
'Access-Control-Allow-Methods': methods,
|
|
183
|
+
'Access-Control-Allow-Headers': 'Authorization, Accept, Content-Type',
|
|
184
|
+
'Access-Control-Expose-Headers': 'Retry-After',
|
|
185
|
+
'Access-Control-Max-Age': '600',
|
|
186
|
+
Vary: 'Origin',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Only the headers the platform needs: Authorization, Content-Type and Accept. */
|
|
191
|
+
function forwardHeaders(req, accept) {
|
|
192
|
+
const headers = { Accept: req.headers.accept ?? accept };
|
|
193
|
+
if (req.headers.authorization) headers.Authorization = req.headers.authorization;
|
|
194
|
+
if (req.headers['content-type']) headers['Content-Type'] = req.headers['content-type'];
|
|
195
|
+
return headers;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The app's platform calls, sent on with only the headers the platform needs. The answer to `GET ai` has its
|
|
200
|
+
* `streamUrl` (a Lambda function URL, whose CORS doesn't allow this page) replaced with this host's `/ai-stream`.
|
|
201
|
+
*/
|
|
202
|
+
async function proxy(req, res, url, { platformBase, appOrigin, hostOrigin, ai, fetchImpl }) {
|
|
203
|
+
const cors = corsHeaders(appOrigin, 'GET, POST, PUT, DELETE');
|
|
204
|
+
if (req.method === 'OPTIONS') {
|
|
205
|
+
res.writeHead(204, cors);
|
|
206
|
+
return res.end();
|
|
207
|
+
}
|
|
208
|
+
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req, MAX_PROXY_BODY);
|
|
209
|
+
const target = `${platformBase}${url.pathname.slice('/api'.length)}${url.search}`;
|
|
210
|
+
const response = await fetchImpl(target, { method: req.method, headers: forwardHeaders(req, 'application/json'), body });
|
|
211
|
+
const out = { ...cors, 'Content-Type': response.headers.get('content-type') ?? 'application/json', 'Cache-Control': 'no-store' };
|
|
212
|
+
if (response.headers.has('retry-after')) out['Retry-After'] = response.headers.get('retry-after');
|
|
213
|
+
let payload = Buffer.from(await response.arrayBuffer());
|
|
214
|
+
if (req.method === 'GET' && response.ok && /^\/api\/v\d+\/ai$/.test(url.pathname)) {
|
|
215
|
+
payload = rewriteAiDiscovery(payload, `${hostOrigin}/ai-stream`, ai);
|
|
216
|
+
}
|
|
217
|
+
res.writeHead(response.status, out);
|
|
218
|
+
res.end(payload);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A real AI stream URL: https on a Lambda function URL host (`*.on.aws`), path `/v1/ai/messages`, nothing else. */
|
|
222
|
+
export function isAiStreamUrl(value) {
|
|
223
|
+
try {
|
|
224
|
+
const url = new URL(value);
|
|
225
|
+
return url.protocol === 'https:' && url.hostname.endsWith('.on.aws') && !url.port && !url.username && !url.password
|
|
226
|
+
&& url.pathname === '/v1/ai/messages' && !url.search && !url.hash;
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Remembers the `streamUrl` of a `GET ai` answer and points the app at `localStream` instead. Any other stream URL
|
|
234
|
+
* (e.g. a local platform's own) is left as it is and never proxied.
|
|
235
|
+
*/
|
|
236
|
+
function rewriteAiDiscovery(payload, localStream, ai) {
|
|
237
|
+
let body;
|
|
238
|
+
try {
|
|
239
|
+
body = JSON.parse(payload.toString('utf8'));
|
|
240
|
+
} catch {
|
|
241
|
+
return payload;
|
|
242
|
+
}
|
|
243
|
+
if (!body || !isAiStreamUrl(body.streamUrl)) return payload;
|
|
244
|
+
ai.streamUrl = body.streamUrl;
|
|
245
|
+
return Buffer.from(JSON.stringify({ ...body, streamUrl: localStream }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `POST /ai-stream`: the app's AI request, sent on to the stream URL remembered from `GET ai`, with the event stream
|
|
250
|
+
* piped back as it arrives (not buffered). Stops the upstream request when the app goes away.
|
|
251
|
+
*/
|
|
252
|
+
async function proxyAiStream(req, res, { appOrigin, ai, fetchImpl }) {
|
|
253
|
+
const cors = corsHeaders(appOrigin, 'POST');
|
|
254
|
+
if (req.method === 'OPTIONS') {
|
|
255
|
+
res.writeHead(204, cors);
|
|
256
|
+
return res.end();
|
|
257
|
+
}
|
|
258
|
+
if (req.method !== 'POST') {
|
|
259
|
+
res.writeHead(405, { ...cors, Allow: 'POST', 'Content-Type': 'text/plain' });
|
|
260
|
+
return res.end('Method not allowed');
|
|
261
|
+
}
|
|
262
|
+
if (!ai.streamUrl) {
|
|
263
|
+
res.writeHead(503, { ...cors, 'Content-Type': 'application/problem+json', 'Cache-Control': 'no-store' });
|
|
264
|
+
return res.end(JSON.stringify({ status: 503, code: 'ai_not_configured', detail: 'No AI stream URL yet: the app must call GET ai first.' }));
|
|
265
|
+
}
|
|
266
|
+
const body = await readBody(req, MAX_PROXY_BODY);
|
|
267
|
+
const upstream = new AbortController();
|
|
268
|
+
res.on('close', () => {
|
|
269
|
+
if (!res.writableFinished) upstream.abort();
|
|
270
|
+
});
|
|
271
|
+
const response = await fetchImpl(ai.streamUrl, { method: 'POST', headers: forwardHeaders(req, 'text/event-stream'), body, signal: upstream.signal });
|
|
272
|
+
const out = { ...cors, 'Content-Type': response.headers.get('content-type') ?? 'text/event-stream', 'Cache-Control': 'no-store' };
|
|
273
|
+
if (response.headers.has('retry-after')) out['Retry-After'] = response.headers.get('retry-after');
|
|
274
|
+
res.writeHead(response.status, out);
|
|
275
|
+
res.flushHeaders();
|
|
276
|
+
try {
|
|
277
|
+
for await (const chunk of response.body ?? []) {
|
|
278
|
+
res.write(chunk);
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// The app went away (or the stream broke): nothing more to send.
|
|
282
|
+
}
|
|
283
|
+
res.end();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function readBody(req, limit) {
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const chunks = [];
|
|
289
|
+
let size = 0;
|
|
290
|
+
req.on('data', (chunk) => {
|
|
291
|
+
size += chunk.length;
|
|
292
|
+
if (size > limit) {
|
|
293
|
+
reject(Object.assign(new Error('Request body too large.'), { status: 413 }));
|
|
294
|
+
req.destroy();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
chunks.push(chunk);
|
|
298
|
+
});
|
|
299
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
300
|
+
req.on('error', reject);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function send(res, status, type, body) {
|
|
305
|
+
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
306
|
+
res.end(body);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Runs the app's `npm run dev` (its own output stays visible). */
|
|
310
|
+
export function npmRunDev({ cwd }) {
|
|
311
|
+
return spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'dev'], { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Polls the URL until it answers (any status), up to `timeoutMs`. */
|
|
315
|
+
export async function waitForUrl(url, { timeoutMs = 60_000, intervalMs = 500, signal, fetchImpl = fetch } = {}) {
|
|
316
|
+
const deadline = Date.now() + timeoutMs;
|
|
317
|
+
while (Date.now() < deadline && !signal?.aborted) {
|
|
318
|
+
try {
|
|
319
|
+
await fetchImpl(url, { method: 'GET', signal: AbortSignal.timeout(2000) });
|
|
320
|
+
return true;
|
|
321
|
+
} catch {
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
329
|
+
|
|
330
|
+
/** The host page: a toolbar (client, site, user, dates, theme), the app, and panels for toasts, events and the token. */
|
|
331
|
+
export function hostPage(settings) {
|
|
332
|
+
const today = new Date();
|
|
333
|
+
const from = new Date(today.getTime() - 29 * 86_400_000);
|
|
334
|
+
const initial = { ...settings, from: from.toISOString().slice(0, 10), to: today.toISOString().slice(0, 10), theme: 'light' };
|
|
335
|
+
return `<!doctype html>
|
|
336
|
+
<html lang="en">
|
|
337
|
+
<head>
|
|
338
|
+
<meta charset="utf-8">
|
|
339
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
340
|
+
<title>${escapeHtml(settings.name)} · hawk-app dev</title>
|
|
341
|
+
<style>
|
|
342
|
+
:root { --bg: #f6f7f9; --panel: #fff; --text: #1d2330; --muted: #667085; --border: #d0d5dd; --accent: #f26b1d; }
|
|
343
|
+
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
|
344
|
+
body.dark { --bg: #14171c; --panel: #1d2128; --text: #e6e8eb; --muted: #98a2b3; --border: #343a46; }
|
|
345
|
+
header { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
|
346
|
+
header h1 { font-size: 16px; margin: 0 16px 6px 0; }
|
|
347
|
+
header h1 small { color: var(--muted); font-weight: normal; }
|
|
348
|
+
label { display: flex; flex-direction: column; font-size: 12px; color: var(--muted); gap: 2px; }
|
|
349
|
+
input, select, button { font: inherit; padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--panel); color: var(--text); }
|
|
350
|
+
button { cursor: pointer; } button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
351
|
+
main { display: grid; grid-template-columns: 1fr 340px; gap: 12px; padding: 12px 16px; }
|
|
352
|
+
#title { font-size: 18px; margin: 0 0 8px; }
|
|
353
|
+
hawk-app-frame { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; min-height: 400px; }
|
|
354
|
+
aside section { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; margin-bottom: 12px; }
|
|
355
|
+
aside h2 { font-size: 13px; margin: 0 0 6px; }
|
|
356
|
+
aside pre { white-space: pre-wrap; word-break: break-all; font-size: 11px; margin: 0; max-height: 260px; overflow: auto; }
|
|
357
|
+
#events div { font-size: 12px; border-top: 1px solid var(--border); padding: 3px 0; }
|
|
358
|
+
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
359
|
+
.toast { padding: 8px 12px; border-radius: 4px; color: #fff; background: #344054; box-shadow: 0 2px 6px rgba(0,0,0,.2); }
|
|
360
|
+
.toast.success { background: #12b76a; } .toast.warning { background: #f79009; } .toast.error { background: #f04438; }
|
|
361
|
+
@media (max-width: 900px) { main { grid-template-columns: 1fr; } }
|
|
362
|
+
</style>
|
|
363
|
+
</head>
|
|
364
|
+
<body>
|
|
365
|
+
<header>
|
|
366
|
+
<h1>${escapeHtml(settings.name)} <small>hawk-app dev</small></h1>
|
|
367
|
+
<label>Client <input id="clientId" size="10"></label>
|
|
368
|
+
<label>Site (engine) <input id="engineId" size="8" placeholder="none"></label>
|
|
369
|
+
<label>User <select id="internal"><option value="true">Bridgeline user</option><option value="false">Client user</option></select></label>
|
|
370
|
+
<label>From <input id="from" type="date"></label>
|
|
371
|
+
<label>To <input id="to" type="date"></label>
|
|
372
|
+
<label>Theme <select id="theme"><option>light</option><option>dark</option></select></label>
|
|
373
|
+
<button class="primary" id="apply">Apply</button>
|
|
374
|
+
<button id="reload">Reload app</button>
|
|
375
|
+
</header>
|
|
376
|
+
<main>
|
|
377
|
+
<div>
|
|
378
|
+
<p id="title"></p>
|
|
379
|
+
<hawk-app-frame id="frame" title="${escapeHtml(settings.name)}"></hawk-app-frame>
|
|
380
|
+
</div>
|
|
381
|
+
<aside>
|
|
382
|
+
<section><h2>Token</h2><pre id="token">—</pre></section>
|
|
383
|
+
<section><h2>Messages from the app</h2><div id="events"></div></section>
|
|
384
|
+
</aside>
|
|
385
|
+
</main>
|
|
386
|
+
<div id="toasts"></div>
|
|
387
|
+
<script type="module">
|
|
388
|
+
import { defineHawkAppFrame } from '/host.js';
|
|
389
|
+
defineHawkAppFrame();
|
|
390
|
+
const initial = ${JSON.stringify(initial).replace(/</g, '\\u003c')};
|
|
391
|
+
const $ = (id) => document.getElementById(id);
|
|
392
|
+
const frame = $('frame');
|
|
393
|
+
let state = { ...initial };
|
|
394
|
+
for (const key of ['clientId', 'engineId', 'from', 'to', 'theme']) $(key).value = state[key] ?? '';
|
|
395
|
+
$('internal').value = String(state.internal);
|
|
396
|
+
|
|
397
|
+
const event = (text) => { const row = document.createElement('div'); row.textContent = new Date().toLocaleTimeString() + ' ' + text; $('events').prepend(row); };
|
|
398
|
+
const claims = (token) => JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
399
|
+
|
|
400
|
+
function context(token) {
|
|
401
|
+
const c = claims(token);
|
|
402
|
+
return {
|
|
403
|
+
protocolVersion: 1,
|
|
404
|
+
app: { slug: state.slug },
|
|
405
|
+
tenant: { clientId: state.clientId },
|
|
406
|
+
user: { id: c.sub, roles: [] },
|
|
407
|
+
engine: state.engineId ? { id: state.engineId } : null,
|
|
408
|
+
site: state.engineId ? { id: state.engineId } : null,
|
|
409
|
+
dateRange: { from: state.from, to: state.to },
|
|
410
|
+
locale: navigator.language || 'en-US',
|
|
411
|
+
theme: { mode: state.theme, tokens: {} },
|
|
412
|
+
apiBaseUrl: location.origin + '/api/v1/',
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function token(forceRefresh) {
|
|
417
|
+
const res = await fetch('/token', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
418
|
+
body: JSON.stringify({ clientId: state.clientId, engineId: state.engineId || null, internal: state.internal, forceRefresh }) });
|
|
419
|
+
const body = await res.json();
|
|
420
|
+
if (!res.ok) { event('token: ' + (body.error || res.status)); throw new Error(body.error || 'No token'); }
|
|
421
|
+
const c = claims(body.token);
|
|
422
|
+
$('token').textContent = JSON.stringify({ expiresAt: body.expiresAt, version: body.version, user: c.sub, client: c.client_id, engine: c.engine ?? null,
|
|
423
|
+
internal: c.internal, scopes: c.scope, hawksearch: body.hawkSearch }, null, 2);
|
|
424
|
+
return body;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
frame.tokenProvider = async ({ forceRefresh }) => { const t = await token(forceRefresh); return { token: t.token, expiresAt: t.expiresAt }; };
|
|
428
|
+
frame.addEventListener('hawk-ready', () => event('ready'));
|
|
429
|
+
frame.addEventListener('hawk-title', (e) => { $('title').textContent = e.detail.title; document.title = e.detail.title + ' · hawk-app dev'; event('setTitle: ' + e.detail.title); });
|
|
430
|
+
frame.addEventListener('hawk-navigate', (e) => event('navigate: ' + JSON.stringify(e.detail)));
|
|
431
|
+
frame.addEventListener('hawk-toast', (e) => {
|
|
432
|
+
const t = document.createElement('div'); t.className = 'toast ' + (e.detail.variant || 'info'); t.textContent = e.detail.message;
|
|
433
|
+
$('toasts').append(t); setTimeout(() => t.remove(), 5000); event('toast (' + (e.detail.variant || 'info') + '): ' + e.detail.message);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
async function load(reloadApp) {
|
|
437
|
+
document.body.classList.toggle('dark', state.theme === 'dark');
|
|
438
|
+
const t = await token(false).catch(() => null);
|
|
439
|
+
if (!t) return;
|
|
440
|
+
frame.context = context(t.token);
|
|
441
|
+
if (reloadApp) { frame.removeAttribute('src'); frame.setAttribute('src', initial.appUrl); }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
$('apply').addEventListener('click', () => {
|
|
445
|
+
const before = state;
|
|
446
|
+
state = { ...state, clientId: $('clientId').value.trim(), engineId: $('engineId').value.trim(), internal: $('internal').value === 'true',
|
|
447
|
+
from: $('from').value, to: $('to').value, theme: $('theme').value };
|
|
448
|
+
// A new client, site or user needs a new token: reload the app; dates and theme are context changes.
|
|
449
|
+
const identity = (s) => s.clientId + '|' + s.engineId + '|' + s.internal;
|
|
450
|
+
load(identity(before) !== identity(state));
|
|
451
|
+
event('context: ' + state.clientId + (state.engineId ? ' / site ' + state.engineId : '') + ' · ' + state.from + '..' + state.to + ' · ' + state.theme);
|
|
452
|
+
});
|
|
453
|
+
$('reload').addEventListener('click', () => load(true));
|
|
454
|
+
load(true);
|
|
455
|
+
</script>
|
|
456
|
+
</body>
|
|
457
|
+
</html>
|
|
458
|
+
`;
|
|
459
|
+
}
|