@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/docs/showcase/README.md
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
# Hawk Apps Showcase
|
|
2
|
-
|
|
3
|
-
Every Hawk Apps feature in one app, one tab per feature, each call showing its outcome, timing and error code. It is
|
|
4
|
-
built against this repo's bridge and CLI (npm workspace), so it always shows the current code, including unreleased
|
|
5
|
-
changes, and CI builds it on every package change. It is not published to npm: copy from it, or start a new app with
|
|
6
|
-
`npx hawk-app create`.
|
|
7
|
-
|
|
8
|
-
Slug `showcase`, visibility `internal`: Bridgeline staff see it on every site, clients never do.
|
|
9
|
-
|
|
10
|
-
| Tab | Shows | Scope |
|
|
11
|
-
|---|---|---|
|
|
12
|
-
| Context | the handshake context (client, user, engine, dates, theme) | — |
|
|
13
|
-
| Analytics | `app.analytics.report` | `reports:read` |
|
|
14
|
-
| Dashboard | `app.dashboard.get`, incl. the versioned-path rejection; a write test that creates a synonym marked `hawk-apps-showcase-…`, reads it back, deletes it and checks it's gone (404), plus a refused write to `Field` (403 `path_not_allowed`). Writes the current client's real HawkSearch data, so use a test client | `hawksearch:read`, `hawksearch:write` |
|
|
15
|
-
| Search | `app.hawksearch.search` / `autocomplete`, live and preview | `hawksearch:search` |
|
|
16
|
-
| Queries | `app.data.query`: an allowed table, a cached repeat, a sensitive table (needs the per-app opt-in in the analytics portal), a 5-query burst (concurrency limit + retry), error cases | `data:read` |
|
|
17
|
-
| Functions | `app.functions.call`: `functions/showcase.ts` runs every capability step by step (zod validation, context, the `TEST_SECRET` secret, `ctx.platform` analytics + dashboard + query in parallel, Athena with `ctx.aws`, outbound `fetch`, CSV and gzip, then `ctx.platform.email.send` of a summary to the signed-in user); `functions/echo.ts`, a 7-call burst, a missing function | `functions:invoke`, `email:send` |
|
|
18
|
-
| Jobs | `app.functions.start` / `wait` on `functions/long-task.ts` (a background job), progress, and the `daily-check` schedule | `functions:invoke` |
|
|
19
|
-
| System parameters | `app.hawksearch.systemParameters`: list the declared ones, set one (empty clears it), and a refused undeclared one (404 `parameter_not_found`). Writes the current engine's real settings, so use a test engine | `hawksearch:sysparams:write` |
|
|
20
|
-
| AI | `app.ai.stream` / `generate` on the `fast` and `smart` tiers, stop, usage and `app.ai.info()` | `ai:invoke` |
|
|
21
|
-
| Host | toast, setTitle, navigate, auto-resize | — |
|
|
22
|
-
|
|
23
|
-
## Running it
|
|
24
|
-
|
|
25
|
-
From the repo root (one `npm install` covers the packages and this app):
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
npm install
|
|
29
|
-
npm run build # the bridge, which this app imports
|
|
30
|
-
npm run dev -w examples/showcase # http://localhost:3100/showcase/ — open it in HawkSearch with ?devUrl=…
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Deploying uses your own publisher key (`npx hawk-app login` once per machine), from this folder:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
cd examples/showcase
|
|
37
|
-
npx hawk-app validate
|
|
38
|
-
npx hawk-app register # register the app / update its settings from hawk-app.json
|
|
39
|
-
npx hawk-app deploy dist # builds and deploys a new version (a draft; approvers make it live)
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Before the Functions and Jobs tabs work: an admin enables functions for the app in the analytics portal, and
|
|
43
|
-
`npx hawk-app secrets set TEST_SECRET` sets its secret (any value). `npx hawk-app logs echo --follow` shows a function's
|
|
44
|
-
logs. Queries live in `queries/` (`searches-by-day` on an allowed table, `assistant-prompts-by-day` on a sensitive one).
|
|
45
|
-
|
|
46
|
-
To try a published release instead of this repo's code, create an app with `npx hawk-app create` (which installs the
|
|
47
|
-
packages from npm) rather than changing this one.
|
|
1
|
+
# Hawk Apps Showcase
|
|
2
|
+
|
|
3
|
+
Every Hawk Apps feature in one app, one tab per feature, each call showing its outcome, timing and error code. It is
|
|
4
|
+
built against this repo's bridge and CLI (npm workspace), so it always shows the current code, including unreleased
|
|
5
|
+
changes, and CI builds it on every package change. It is not published to npm: copy from it, or start a new app with
|
|
6
|
+
`npx hawk-app create`.
|
|
7
|
+
|
|
8
|
+
Slug `showcase`, visibility `internal`: Bridgeline staff see it on every site, clients never do.
|
|
9
|
+
|
|
10
|
+
| Tab | Shows | Scope |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| Context | the handshake context (client, user, engine, dates, theme) | — |
|
|
13
|
+
| Analytics | `app.analytics.report` | `reports:read` |
|
|
14
|
+
| Dashboard | `app.dashboard.get`, incl. the versioned-path rejection; a write test that creates a synonym marked `hawk-apps-showcase-…`, reads it back, deletes it and checks it's gone (404), plus a refused write to `Field` (403 `path_not_allowed`). Writes the current client's real HawkSearch data, so use a test client | `hawksearch:read`, `hawksearch:write` |
|
|
15
|
+
| Search | `app.hawksearch.search` / `autocomplete`, live and preview | `hawksearch:search` |
|
|
16
|
+
| Queries | `app.data.query`: an allowed table, a cached repeat, a sensitive table (needs the per-app opt-in in the analytics portal), a 5-query burst (concurrency limit + retry), error cases | `data:read` |
|
|
17
|
+
| Functions | `app.functions.call`: `functions/showcase.ts` runs every capability step by step (zod validation, context, the `TEST_SECRET` secret, `ctx.platform` analytics + dashboard + query in parallel, Athena with `ctx.aws`, outbound `fetch`, CSV and gzip, then `ctx.platform.email.send` of a summary to the signed-in user); `functions/echo.ts`, a 7-call burst, a missing function | `functions:invoke`, `email:send` |
|
|
18
|
+
| Jobs | `app.functions.start` / `wait` on `functions/long-task.ts` (a background job), progress, and the `daily-check` schedule | `functions:invoke` |
|
|
19
|
+
| System parameters | `app.hawksearch.systemParameters`: list the declared ones, set one (empty clears it), and a refused undeclared one (404 `parameter_not_found`). Writes the current engine's real settings, so use a test engine | `hawksearch:sysparams:write` |
|
|
20
|
+
| AI | `app.ai.stream` / `generate` on the `fast` and `smart` tiers, stop, usage and `app.ai.info()` | `ai:invoke` |
|
|
21
|
+
| Host | toast, setTitle, navigate, auto-resize | — |
|
|
22
|
+
|
|
23
|
+
## Running it
|
|
24
|
+
|
|
25
|
+
From the repo root (one `npm install` covers the packages and this app):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npm run build # the bridge, which this app imports
|
|
30
|
+
npm run dev -w examples/showcase # http://localhost:3100/showcase/ — open it in HawkSearch with ?devUrl=…
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Deploying uses your own publisher key (`npx hawk-app login` once per machine), from this folder:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd examples/showcase
|
|
37
|
+
npx hawk-app validate
|
|
38
|
+
npx hawk-app register # register the app / update its settings from hawk-app.json
|
|
39
|
+
npx hawk-app deploy dist # builds and deploys a new version (a draft; approvers make it live)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Before the Functions and Jobs tabs work: an admin enables functions for the app in the analytics portal, and
|
|
43
|
+
`npx hawk-app secrets set TEST_SECRET` sets its secret (any value). `npx hawk-app logs echo --follow` shows a function's
|
|
44
|
+
logs. Queries live in `queries/` (`searches-by-day` on an allowed table, `assistant-prompts-by-day` on a sensitive one).
|
|
45
|
+
|
|
46
|
+
To try a published release instead of this repo's code, create an app with `npx hawk-app create` (which installs the
|
|
47
|
+
packages from npm) rather than changing this one.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
2
|
-
|
|
3
|
-
/** Echoes its input with what the function knows about the call: exercises ctx, secrets, config and ctx.platform. */
|
|
4
|
-
export const handler: HawkFunction<unknown, Record<string, unknown>> = async (input, ctx) => ({
|
|
5
|
-
input,
|
|
6
|
-
client: ctx.client.id,
|
|
7
|
-
user: ctx.user.id,
|
|
8
|
-
version: ctx.version,
|
|
9
|
-
hasSecret: !!ctx.secrets.TEST_SECRET,
|
|
10
|
-
config: ctx.config,
|
|
11
|
-
searches: (await ctx.platform.analytics.report('searches', { limit: 5 })).data.length,
|
|
12
|
-
});
|
|
1
|
+
import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
2
|
+
|
|
3
|
+
/** Echoes its input with what the function knows about the call: exercises ctx, secrets, config and ctx.platform. */
|
|
4
|
+
export const handler: HawkFunction<unknown, Record<string, unknown>> = async (input, ctx) => ({
|
|
5
|
+
input,
|
|
6
|
+
client: ctx.client.id,
|
|
7
|
+
user: ctx.user.id,
|
|
8
|
+
version: ctx.version,
|
|
9
|
+
hasSecret: !!ctx.secrets.TEST_SECRET,
|
|
10
|
+
config: ctx.config,
|
|
11
|
+
searches: (await ctx.platform.analytics.report('searches', { limit: 5 })).data.length,
|
|
12
|
+
});
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
2
|
-
|
|
3
|
-
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A background job (`"job": true`): counts through `steps` steps of `stepSeconds` each, reporting progress, then returns
|
|
7
|
-
* what it knows about the run. Started from the Jobs tab, or by the `daily-check` schedule. `fail: true` throws at
|
|
8
|
-
* the end, to test failed jobs.
|
|
9
|
-
*/
|
|
10
|
-
export const handler: HawkFunction<{ steps?: number; stepSeconds?: number; fail?: boolean } | null, Record<string, unknown>> = async (input, ctx) => {
|
|
11
|
-
const steps = Math.min(Math.max(input?.steps ?? 10, 1), 60);
|
|
12
|
-
const stepSeconds = Math.min(Math.max(input?.stepSeconds ?? 3, 0), 30);
|
|
13
|
-
const started = Date.now();
|
|
14
|
-
for (let i = 1; i <= steps; i++) {
|
|
15
|
-
await sleep(stepSeconds * 1000);
|
|
16
|
-
await ctx.job?.progress(Math.round((100 * i) / steps), `Step ${i} of ${steps}`);
|
|
17
|
-
}
|
|
18
|
-
if (input?.fail) {
|
|
19
|
-
throw new Error('Failed on purpose (fail: true)');
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
steps,
|
|
23
|
-
seconds: Math.round((Date.now() - started) / 1000),
|
|
24
|
-
job: ctx.job?.id ?? null,
|
|
25
|
-
trigger: ctx.trigger,
|
|
26
|
-
client: ctx.client.id,
|
|
27
|
-
user: ctx.user.id,
|
|
28
|
-
version: ctx.version,
|
|
29
|
-
searches: (await ctx.platform.analytics.report('searches', { limit: 5 })).data.length,
|
|
30
|
-
};
|
|
31
|
-
};
|
|
1
|
+
import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
2
|
+
|
|
3
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A background job (`"job": true`): counts through `steps` steps of `stepSeconds` each, reporting progress, then returns
|
|
7
|
+
* what it knows about the run. Started from the Jobs tab, or by the `daily-check` schedule. `fail: true` throws at
|
|
8
|
+
* the end, to test failed jobs.
|
|
9
|
+
*/
|
|
10
|
+
export const handler: HawkFunction<{ steps?: number; stepSeconds?: number; fail?: boolean } | null, Record<string, unknown>> = async (input, ctx) => {
|
|
11
|
+
const steps = Math.min(Math.max(input?.steps ?? 10, 1), 60);
|
|
12
|
+
const stepSeconds = Math.min(Math.max(input?.stepSeconds ?? 3, 0), 30);
|
|
13
|
+
const started = Date.now();
|
|
14
|
+
for (let i = 1; i <= steps; i++) {
|
|
15
|
+
await sleep(stepSeconds * 1000);
|
|
16
|
+
await ctx.job?.progress(Math.round((100 * i) / steps), `Step ${i} of ${steps}`);
|
|
17
|
+
}
|
|
18
|
+
if (input?.fail) {
|
|
19
|
+
throw new Error('Failed on purpose (fail: true)');
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
steps,
|
|
23
|
+
seconds: Math.round((Date.now() - started) / 1000),
|
|
24
|
+
job: ctx.job?.id ?? null,
|
|
25
|
+
trigger: ctx.trigger,
|
|
26
|
+
client: ctx.client.id,
|
|
27
|
+
user: ctx.user.id,
|
|
28
|
+
version: ctx.version,
|
|
29
|
+
searches: (await ctx.platform.analytics.report('searches', { limit: 5 })).data.length,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -1,186 +1,186 @@
|
|
|
1
|
-
import { AthenaClient, GetQueryExecutionCommand, GetQueryResultsCommand, StartQueryExecutionCommand } from '@aws-sdk/client-athena';
|
|
2
|
-
import type { HawkFunction, HawkFunctionContext } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
|
-
import { gzipSync } from 'node:zlib';
|
|
5
|
-
import { z } from 'zod';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Showcase: everything a Hawk Apps function can do today, one step at a time. Each step reports ok/fail, timing and
|
|
9
|
-
* a short summary, and a failing step never stops the others, so the result doubles as a health check of the
|
|
10
|
-
* function runtime. Called from the Functions tab ("Run showcase").
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
// 1. npm packages are bundled with the function (zod here; the AWS SDK below).
|
|
14
|
-
const Input = z.object({
|
|
15
|
-
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
16
|
-
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
17
|
-
url: z.string().url().default('https://api.github.com/zen'),
|
|
18
|
-
dashboardPath: z.string().default('Synonym'),
|
|
19
|
-
});
|
|
20
|
-
type Input = z.infer<typeof Input>;
|
|
21
|
-
|
|
22
|
-
interface Step {
|
|
23
|
-
name: string;
|
|
24
|
-
ok: boolean;
|
|
25
|
-
ms: number;
|
|
26
|
-
summary: string;
|
|
27
|
-
detail?: unknown;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface ShowcaseResult {
|
|
31
|
-
steps: Step[];
|
|
32
|
-
totalMs: number;
|
|
33
|
-
runtime: Record<string, unknown>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function step(name: string, run: () => Promise<{ summary: string; detail?: unknown }>): Promise<Step> {
|
|
37
|
-
const started = performance.now();
|
|
38
|
-
try {
|
|
39
|
-
const { summary, detail } = await run();
|
|
40
|
-
return { name, ok: true, ms: Math.round(performance.now() - started), summary, detail };
|
|
41
|
-
} catch (e) {
|
|
42
|
-
const error = e as { message?: string; code?: string; status?: number };
|
|
43
|
-
const code = error.code ? ` (${error.code}${error.status ? ` ${error.status}` : ''})` : '';
|
|
44
|
-
return { name, ok: false, ms: Math.round(performance.now() - started), summary: `${error.message ?? String(e)}${code}` };
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const handler: HawkFunction<unknown, ShowcaseResult> = async (raw, ctx) => {
|
|
49
|
-
const started = performance.now();
|
|
50
|
-
const steps: Step[] = [];
|
|
51
|
-
|
|
52
|
-
// Validate the input first: a bad input fails fast with a readable message (thrown errors become 502 function_failed).
|
|
53
|
-
const parsed = Input.safeParse(raw ?? {});
|
|
54
|
-
if (!parsed.success) {
|
|
55
|
-
throw new Error(`Invalid input: ${parsed.error.issues.map((i) => `${i.path.join('.') || 'input'} ${i.message}`).join('; ')}`);
|
|
56
|
-
}
|
|
57
|
-
const input = parsed.data;
|
|
58
|
-
steps.push({ name: 'Validate input (zod, an npm package)', ok: true, ms: 0, summary: `from ${input.from} to ${input.to}` });
|
|
59
|
-
|
|
60
|
-
// 2. Who is calling, for which client and version: ctx comes from the signed app token, never from the browser.
|
|
61
|
-
steps.push(await step('Context (who, which client, which version)', async () => ({
|
|
62
|
-
summary: `client ${ctx.client.id} · user ${ctx.user.id}${ctx.internal ? ' (Bridgeline)' : ''} · ${ctx.app.slug} v${ctx.version}`,
|
|
63
|
-
detail: { client: ctx.client, user: { id: ctx.user.id, roles: ctx.user.roles }, internal: ctx.internal, app: ctx.app, version: ctx.version, host: ctx.host },
|
|
64
|
-
})));
|
|
65
|
-
|
|
66
|
-
// 3. Secrets: only the ones declared in hawk-app.json, read by the platform at call time. Never return the value.
|
|
67
|
-
steps.push(await step('Secret (TEST_SECRET)', async () => {
|
|
68
|
-
const secret = ctx.secrets.TEST_SECRET;
|
|
69
|
-
if (!secret) throw new Error('TEST_SECRET is not set: npx hawk-app secrets set TEST_SECRET');
|
|
70
|
-
const fingerprint = createHash('sha256').update(secret).digest('hex').slice(0, 12);
|
|
71
|
-
return { summary: `${secret.length} characters · sha256 ${fingerprint}… (value not shown)` };
|
|
72
|
-
}));
|
|
73
|
-
|
|
74
|
-
// 4-6. The platform, as the current user (same scopes as the app), in parallel.
|
|
75
|
-
const [analytics, dashboard, query] = await Promise.all([
|
|
76
|
-
step('Analytics API (ctx.platform.analytics, reports:read)', async () => {
|
|
77
|
-
const report = await ctx.platform.analytics.report<{ search_count?: number | string }>('searches', { startDate: input.from, endDate: input.to, limit: 1000 });
|
|
78
|
-
const total = report.data.reduce((sum, row) => sum + Number(row.search_count ?? 0), 0);
|
|
79
|
-
return { summary: `${report.data.length} rows · ${total.toLocaleString()} searches`, detail: report.data.slice(0, 3) };
|
|
80
|
-
}),
|
|
81
|
-
step(`Dashboard API (ctx.platform.dashboard, hawksearch:read): ${input.dashboardPath}`, async () => {
|
|
82
|
-
const data = await ctx.platform.dashboard.get<unknown>(input.dashboardPath);
|
|
83
|
-
return { summary: Array.isArray(data) ? `${data.length} items` : 'object', detail: Array.isArray(data) ? data.slice(0, 2) : data };
|
|
84
|
-
}),
|
|
85
|
-
step("App query (ctx.platform.data, data:read): 'searches-by-day'", async () => {
|
|
86
|
-
const result = await ctx.platform.data.query<{ search_date: string; searches: number }>('searches-by-day', { from: input.from, to: input.to });
|
|
87
|
-
return {
|
|
88
|
-
summary: `${result.rowCount} days · ${result.bytesScanned} B scanned${result.cached ? ' · cached' : ''}`,
|
|
89
|
-
detail: result.rows,
|
|
90
|
-
};
|
|
91
|
-
}),
|
|
92
|
-
]);
|
|
93
|
-
steps.push(analytics, dashboard, query);
|
|
94
|
-
|
|
95
|
-
// 7. Athena directly with the AWS SDK and client-scoped credentials (ctx.aws: only this client's tables).
|
|
96
|
-
steps.push(await step('Athena directly (AWS SDK + ctx.aws)', async () => {
|
|
97
|
-
const aws = ctx.aws;
|
|
98
|
-
if (!aws) throw new Error('ctx.aws is missing: the app needs the data:read scope');
|
|
99
|
-
const athena = new AthenaClient({
|
|
100
|
-
region: aws.region,
|
|
101
|
-
credentials: { accessKeyId: aws.accessKeyId, secretAccessKey: aws.secretAccessKey, sessionToken: aws.sessionToken },
|
|
102
|
-
});
|
|
103
|
-
const { QueryExecutionId } = await athena.send(new StartQueryExecutionCommand({
|
|
104
|
-
// dailysearches has a row per day *and* request type/engine/…: count distinct dates, not rows.
|
|
105
|
-
QueryString: 'SELECT count(DISTINCT search_date) AS days, sum(search_count) AS searches FROM dailysearches WHERE search_date BETWEEN ? AND ?',
|
|
106
|
-
ExecutionParameters: [`DATE '${input.from}'`, `DATE '${input.to}'`],
|
|
107
|
-
QueryExecutionContext: { Database: aws.database },
|
|
108
|
-
WorkGroup: aws.workGroup,
|
|
109
|
-
ResultConfiguration: { OutputLocation: aws.outputLocation },
|
|
110
|
-
}));
|
|
111
|
-
for (let i = 0; i < 40; i++) {
|
|
112
|
-
const { QueryExecution } = await athena.send(new GetQueryExecutionCommand({ QueryExecutionId }));
|
|
113
|
-
const state = QueryExecution?.Status?.State;
|
|
114
|
-
if (state === 'SUCCEEDED') {
|
|
115
|
-
const results = await athena.send(new GetQueryResultsCommand({ QueryExecutionId }));
|
|
116
|
-
const [days, searches] = results.ResultSet?.Rows?.[1]?.Data?.map((d) => d.VarCharValue) ?? [];
|
|
117
|
-
return { summary: `${days} days · ${Number(searches ?? 0).toLocaleString()} searches · ${QueryExecution?.Statistics?.DataScannedInBytes ?? 0} B scanned` };
|
|
118
|
-
}
|
|
119
|
-
if (state === 'FAILED' || state === 'CANCELLED') throw new Error(QueryExecution?.Status?.StateChangeReason ?? state);
|
|
120
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
121
|
-
}
|
|
122
|
-
throw new Error('Athena query still running after 20 s');
|
|
123
|
-
}));
|
|
124
|
-
|
|
125
|
-
// 8. Outbound HTTP: functions can call any https API (with secrets for authenticated ones).
|
|
126
|
-
steps.push(await step(`Outbound HTTP (fetch): ${input.url}`, async () => {
|
|
127
|
-
const response = await fetch(input.url, { headers: { 'User-Agent': 'hawk-apps-showcase', Accept: 'application/json, text/plain' }, signal: AbortSignal.timeout(5000) });
|
|
128
|
-
const body = await response.text();
|
|
129
|
-
return { summary: `${response.status} · ${body.length} bytes`, detail: body.slice(0, 300) };
|
|
130
|
-
}));
|
|
131
|
-
|
|
132
|
-
// 9. Your own logic: combine the results, build a file, use Node built-ins.
|
|
133
|
-
steps.push(await step('Business logic (combine, CSV, gzip)', async () => {
|
|
134
|
-
const days = query.ok ? ((query.detail as Array<{ search_date: string; searches: number }>) ?? []) : [];
|
|
135
|
-
if (!days.length) throw new Error('No days from the app query to work with');
|
|
136
|
-
const busiest = days.reduce((best, d) => (d.searches > best.searches ? d : best));
|
|
137
|
-
const average = days.reduce((sum, d) => sum + d.searches, 0) / days.length;
|
|
138
|
-
const csv = ['date,searches,vs_average', ...days.map((d) => `${d.search_date},${d.searches},${(d.searches / average - 1).toFixed(3)}`)].join('\n');
|
|
139
|
-
return {
|
|
140
|
-
summary: `busiest ${busiest.search_date} (${busiest.searches}) · average ${Math.round(average)} · CSV ${csv.length} B → gzip ${gzipSync(csv).length} B`,
|
|
141
|
-
detail: csv.split('\n').slice(0, 6).join('\n'),
|
|
142
|
-
};
|
|
143
|
-
}));
|
|
144
|
-
|
|
145
|
-
// 10. Email through the platform (email:send): to the signed-in user only, never to anyone else.
|
|
146
|
-
steps.push(await step('Email (ctx.platform.email)', async () => {
|
|
147
|
-
const to = ctx.user.email;
|
|
148
|
-
if (!to) throw new Error('ctx.user.email is missing: the host did not pass an email address for this user, so nothing was sent');
|
|
149
|
-
const passed = steps.filter((s) => s.ok).length;
|
|
150
|
-
const days = query.ok ? ((query.detail as Array<{ search_date: string; searches: number }>) ?? []) : [];
|
|
151
|
-
const busiest = days.length ? days.reduce((best, d) => (d.searches > best.searches ? d : best)) : undefined;
|
|
152
|
-
const busiestText = busiest ? `${busiest.search_date} (${busiest.searches.toLocaleString()} searches)` : 'not available (the app query returned no days)';
|
|
153
|
-
const { messageId, recipients } = await ctx.platform.email.send({
|
|
154
|
-
to,
|
|
155
|
-
subject: 'Hawk Apps showcase',
|
|
156
|
-
text: [
|
|
157
|
-
`Hawk Apps showcase for client ${ctx.client.id}, ${input.from} to ${input.to}.`,
|
|
158
|
-
`${passed} of ${steps.length} steps passed so far.`,
|
|
159
|
-
`Busiest day: ${busiestText}.`,
|
|
160
|
-
].join('\n'),
|
|
161
|
-
html:
|
|
162
|
-
`<p>Hawk Apps showcase for client <strong>${escapeHtml(ctx.client.id)}</strong>, ${escapeHtml(input.from)} to ${escapeHtml(input.to)}.</p>` +
|
|
163
|
-
`<ul><li>${passed} of ${steps.length} steps passed so far</li><li>Busiest day: ${escapeHtml(busiestText)}</li></ul>`,
|
|
164
|
-
});
|
|
165
|
-
return { summary: `sent to ${recipients} recipient${recipients === 1 ? '' : 's'} · messageId ${messageId}` };
|
|
166
|
-
}));
|
|
167
|
-
|
|
168
|
-
return { steps, totalMs: Math.round(performance.now() - started), runtime: runtime(ctx) };
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
function escapeHtml(value: string): string {
|
|
172
|
-
return value.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Where it ran: safe Lambda facts only (never the function's credentials from process.env). */
|
|
176
|
-
function runtime(ctx: HawkFunctionContext): Record<string, unknown> {
|
|
177
|
-
return {
|
|
178
|
-
node: process.version,
|
|
179
|
-
region: process.env.AWS_REGION,
|
|
180
|
-
function: process.env.AWS_LAMBDA_FUNCTION_NAME,
|
|
181
|
-
lambdaVersion: process.env.AWS_LAMBDA_FUNCTION_VERSION,
|
|
182
|
-
memoryMb: Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE),
|
|
183
|
-
heapUsedMb: Math.round(process.memoryUsage().heapUsed / 1048576),
|
|
184
|
-
appVersion: ctx.version,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
1
|
+
import { AthenaClient, GetQueryExecutionCommand, GetQueryResultsCommand, StartQueryExecutionCommand } from '@aws-sdk/client-athena';
|
|
2
|
+
import type { HawkFunction, HawkFunctionContext } from '@bridgeline-digital/hawk-app-bridge/server';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { gzipSync } from 'node:zlib';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Showcase: everything a Hawk Apps function can do today, one step at a time. Each step reports ok/fail, timing and
|
|
9
|
+
* a short summary, and a failing step never stops the others, so the result doubles as a health check of the
|
|
10
|
+
* function runtime. Called from the Functions tab ("Run showcase").
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// 1. npm packages are bundled with the function (zod here; the AWS SDK below).
|
|
14
|
+
const Input = z.object({
|
|
15
|
+
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
16
|
+
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
17
|
+
url: z.string().url().default('https://api.github.com/zen'),
|
|
18
|
+
dashboardPath: z.string().default('Synonym'),
|
|
19
|
+
});
|
|
20
|
+
type Input = z.infer<typeof Input>;
|
|
21
|
+
|
|
22
|
+
interface Step {
|
|
23
|
+
name: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
ms: number;
|
|
26
|
+
summary: string;
|
|
27
|
+
detail?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ShowcaseResult {
|
|
31
|
+
steps: Step[];
|
|
32
|
+
totalMs: number;
|
|
33
|
+
runtime: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function step(name: string, run: () => Promise<{ summary: string; detail?: unknown }>): Promise<Step> {
|
|
37
|
+
const started = performance.now();
|
|
38
|
+
try {
|
|
39
|
+
const { summary, detail } = await run();
|
|
40
|
+
return { name, ok: true, ms: Math.round(performance.now() - started), summary, detail };
|
|
41
|
+
} catch (e) {
|
|
42
|
+
const error = e as { message?: string; code?: string; status?: number };
|
|
43
|
+
const code = error.code ? ` (${error.code}${error.status ? ` ${error.status}` : ''})` : '';
|
|
44
|
+
return { name, ok: false, ms: Math.round(performance.now() - started), summary: `${error.message ?? String(e)}${code}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const handler: HawkFunction<unknown, ShowcaseResult> = async (raw, ctx) => {
|
|
49
|
+
const started = performance.now();
|
|
50
|
+
const steps: Step[] = [];
|
|
51
|
+
|
|
52
|
+
// Validate the input first: a bad input fails fast with a readable message (thrown errors become 502 function_failed).
|
|
53
|
+
const parsed = Input.safeParse(raw ?? {});
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
throw new Error(`Invalid input: ${parsed.error.issues.map((i) => `${i.path.join('.') || 'input'} ${i.message}`).join('; ')}`);
|
|
56
|
+
}
|
|
57
|
+
const input = parsed.data;
|
|
58
|
+
steps.push({ name: 'Validate input (zod, an npm package)', ok: true, ms: 0, summary: `from ${input.from} to ${input.to}` });
|
|
59
|
+
|
|
60
|
+
// 2. Who is calling, for which client and version: ctx comes from the signed app token, never from the browser.
|
|
61
|
+
steps.push(await step('Context (who, which client, which version)', async () => ({
|
|
62
|
+
summary: `client ${ctx.client.id} · user ${ctx.user.id}${ctx.internal ? ' (Bridgeline)' : ''} · ${ctx.app.slug} v${ctx.version}`,
|
|
63
|
+
detail: { client: ctx.client, user: { id: ctx.user.id, roles: ctx.user.roles }, internal: ctx.internal, app: ctx.app, version: ctx.version, host: ctx.host },
|
|
64
|
+
})));
|
|
65
|
+
|
|
66
|
+
// 3. Secrets: only the ones declared in hawk-app.json, read by the platform at call time. Never return the value.
|
|
67
|
+
steps.push(await step('Secret (TEST_SECRET)', async () => {
|
|
68
|
+
const secret = ctx.secrets.TEST_SECRET;
|
|
69
|
+
if (!secret) throw new Error('TEST_SECRET is not set: npx hawk-app secrets set TEST_SECRET');
|
|
70
|
+
const fingerprint = createHash('sha256').update(secret).digest('hex').slice(0, 12);
|
|
71
|
+
return { summary: `${secret.length} characters · sha256 ${fingerprint}… (value not shown)` };
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// 4-6. The platform, as the current user (same scopes as the app), in parallel.
|
|
75
|
+
const [analytics, dashboard, query] = await Promise.all([
|
|
76
|
+
step('Analytics API (ctx.platform.analytics, reports:read)', async () => {
|
|
77
|
+
const report = await ctx.platform.analytics.report<{ search_count?: number | string }>('searches', { startDate: input.from, endDate: input.to, limit: 1000 });
|
|
78
|
+
const total = report.data.reduce((sum, row) => sum + Number(row.search_count ?? 0), 0);
|
|
79
|
+
return { summary: `${report.data.length} rows · ${total.toLocaleString()} searches`, detail: report.data.slice(0, 3) };
|
|
80
|
+
}),
|
|
81
|
+
step(`Dashboard API (ctx.platform.dashboard, hawksearch:read): ${input.dashboardPath}`, async () => {
|
|
82
|
+
const data = await ctx.platform.dashboard.get<unknown>(input.dashboardPath);
|
|
83
|
+
return { summary: Array.isArray(data) ? `${data.length} items` : 'object', detail: Array.isArray(data) ? data.slice(0, 2) : data };
|
|
84
|
+
}),
|
|
85
|
+
step("App query (ctx.platform.data, data:read): 'searches-by-day'", async () => {
|
|
86
|
+
const result = await ctx.platform.data.query<{ search_date: string; searches: number }>('searches-by-day', { from: input.from, to: input.to });
|
|
87
|
+
return {
|
|
88
|
+
summary: `${result.rowCount} days · ${result.bytesScanned} B scanned${result.cached ? ' · cached' : ''}`,
|
|
89
|
+
detail: result.rows,
|
|
90
|
+
};
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
steps.push(analytics, dashboard, query);
|
|
94
|
+
|
|
95
|
+
// 7. Athena directly with the AWS SDK and client-scoped credentials (ctx.aws: only this client's tables).
|
|
96
|
+
steps.push(await step('Athena directly (AWS SDK + ctx.aws)', async () => {
|
|
97
|
+
const aws = ctx.aws;
|
|
98
|
+
if (!aws) throw new Error('ctx.aws is missing: the app needs the data:read scope');
|
|
99
|
+
const athena = new AthenaClient({
|
|
100
|
+
region: aws.region,
|
|
101
|
+
credentials: { accessKeyId: aws.accessKeyId, secretAccessKey: aws.secretAccessKey, sessionToken: aws.sessionToken },
|
|
102
|
+
});
|
|
103
|
+
const { QueryExecutionId } = await athena.send(new StartQueryExecutionCommand({
|
|
104
|
+
// dailysearches has a row per day *and* request type/engine/…: count distinct dates, not rows.
|
|
105
|
+
QueryString: 'SELECT count(DISTINCT search_date) AS days, sum(search_count) AS searches FROM dailysearches WHERE search_date BETWEEN ? AND ?',
|
|
106
|
+
ExecutionParameters: [`DATE '${input.from}'`, `DATE '${input.to}'`],
|
|
107
|
+
QueryExecutionContext: { Database: aws.database },
|
|
108
|
+
WorkGroup: aws.workGroup,
|
|
109
|
+
ResultConfiguration: { OutputLocation: aws.outputLocation },
|
|
110
|
+
}));
|
|
111
|
+
for (let i = 0; i < 40; i++) {
|
|
112
|
+
const { QueryExecution } = await athena.send(new GetQueryExecutionCommand({ QueryExecutionId }));
|
|
113
|
+
const state = QueryExecution?.Status?.State;
|
|
114
|
+
if (state === 'SUCCEEDED') {
|
|
115
|
+
const results = await athena.send(new GetQueryResultsCommand({ QueryExecutionId }));
|
|
116
|
+
const [days, searches] = results.ResultSet?.Rows?.[1]?.Data?.map((d) => d.VarCharValue) ?? [];
|
|
117
|
+
return { summary: `${days} days · ${Number(searches ?? 0).toLocaleString()} searches · ${QueryExecution?.Statistics?.DataScannedInBytes ?? 0} B scanned` };
|
|
118
|
+
}
|
|
119
|
+
if (state === 'FAILED' || state === 'CANCELLED') throw new Error(QueryExecution?.Status?.StateChangeReason ?? state);
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
121
|
+
}
|
|
122
|
+
throw new Error('Athena query still running after 20 s');
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// 8. Outbound HTTP: functions can call any https API (with secrets for authenticated ones).
|
|
126
|
+
steps.push(await step(`Outbound HTTP (fetch): ${input.url}`, async () => {
|
|
127
|
+
const response = await fetch(input.url, { headers: { 'User-Agent': 'hawk-apps-showcase', Accept: 'application/json, text/plain' }, signal: AbortSignal.timeout(5000) });
|
|
128
|
+
const body = await response.text();
|
|
129
|
+
return { summary: `${response.status} · ${body.length} bytes`, detail: body.slice(0, 300) };
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// 9. Your own logic: combine the results, build a file, use Node built-ins.
|
|
133
|
+
steps.push(await step('Business logic (combine, CSV, gzip)', async () => {
|
|
134
|
+
const days = query.ok ? ((query.detail as Array<{ search_date: string; searches: number }>) ?? []) : [];
|
|
135
|
+
if (!days.length) throw new Error('No days from the app query to work with');
|
|
136
|
+
const busiest = days.reduce((best, d) => (d.searches > best.searches ? d : best));
|
|
137
|
+
const average = days.reduce((sum, d) => sum + d.searches, 0) / days.length;
|
|
138
|
+
const csv = ['date,searches,vs_average', ...days.map((d) => `${d.search_date},${d.searches},${(d.searches / average - 1).toFixed(3)}`)].join('\n');
|
|
139
|
+
return {
|
|
140
|
+
summary: `busiest ${busiest.search_date} (${busiest.searches}) · average ${Math.round(average)} · CSV ${csv.length} B → gzip ${gzipSync(csv).length} B`,
|
|
141
|
+
detail: csv.split('\n').slice(0, 6).join('\n'),
|
|
142
|
+
};
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
// 10. Email through the platform (email:send): to the signed-in user only, never to anyone else.
|
|
146
|
+
steps.push(await step('Email (ctx.platform.email)', async () => {
|
|
147
|
+
const to = ctx.user.email;
|
|
148
|
+
if (!to) throw new Error('ctx.user.email is missing: the host did not pass an email address for this user, so nothing was sent');
|
|
149
|
+
const passed = steps.filter((s) => s.ok).length;
|
|
150
|
+
const days = query.ok ? ((query.detail as Array<{ search_date: string; searches: number }>) ?? []) : [];
|
|
151
|
+
const busiest = days.length ? days.reduce((best, d) => (d.searches > best.searches ? d : best)) : undefined;
|
|
152
|
+
const busiestText = busiest ? `${busiest.search_date} (${busiest.searches.toLocaleString()} searches)` : 'not available (the app query returned no days)';
|
|
153
|
+
const { messageId, recipients } = await ctx.platform.email.send({
|
|
154
|
+
to,
|
|
155
|
+
subject: 'Hawk Apps showcase',
|
|
156
|
+
text: [
|
|
157
|
+
`Hawk Apps showcase for client ${ctx.client.id}, ${input.from} to ${input.to}.`,
|
|
158
|
+
`${passed} of ${steps.length} steps passed so far.`,
|
|
159
|
+
`Busiest day: ${busiestText}.`,
|
|
160
|
+
].join('\n'),
|
|
161
|
+
html:
|
|
162
|
+
`<p>Hawk Apps showcase for client <strong>${escapeHtml(ctx.client.id)}</strong>, ${escapeHtml(input.from)} to ${escapeHtml(input.to)}.</p>` +
|
|
163
|
+
`<ul><li>${passed} of ${steps.length} steps passed so far</li><li>Busiest day: ${escapeHtml(busiestText)}</li></ul>`,
|
|
164
|
+
});
|
|
165
|
+
return { summary: `sent to ${recipients} recipient${recipients === 1 ? '' : 's'} · messageId ${messageId}` };
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
return { steps, totalMs: Math.round(performance.now() - started), runtime: runtime(ctx) };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function escapeHtml(value: string): string {
|
|
172
|
+
return value.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Where it ran: safe Lambda facts only (never the function's credentials from process.env). */
|
|
176
|
+
function runtime(ctx: HawkFunctionContext): Record<string, unknown> {
|
|
177
|
+
return {
|
|
178
|
+
node: process.version,
|
|
179
|
+
region: process.env.AWS_REGION,
|
|
180
|
+
function: process.env.AWS_LAMBDA_FUNCTION_NAME,
|
|
181
|
+
lambdaVersion: process.env.AWS_LAMBDA_FUNCTION_VERSION,
|
|
182
|
+
memoryMb: Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE),
|
|
183
|
+
heapUsedMb: Math.round(process.memoryUsage().heapUsed / 1048576),
|
|
184
|
+
appVersion: ctx.version,
|
|
185
|
+
};
|
|
186
|
+
}
|