@speakspec/astro 0.0.1
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 +21 -0
- package/README.md +193 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +12 -0
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +2 -0
- package/dist/runtime/config.d.ts +46 -0
- package/dist/runtime/config.js +81 -0
- package/dist/runtime/middleware/ai-bot-detect.d.ts +2 -0
- package/dist/runtime/middleware/ai-bot-detect.js +73 -0
- package/dist/runtime/server/cache-store.d.ts +8 -0
- package/dist/runtime/server/cache-store.js +27 -0
- package/dist/runtime/server/routes/webhook.d.ts +2 -0
- package/dist/runtime/server/routes/webhook.js +90 -0
- package/dist/runtime/server/routes/well-known-aidp.d.ts +2 -0
- package/dist/runtime/server/routes/well-known-aidp.js +79 -0
- package/dist/runtime/server/routes/well-known-content.d.ts +2 -0
- package/dist/runtime/server/routes/well-known-content.js +84 -0
- package/dist/runtime/server/routes/well-known-directory.d.ts +2 -0
- package/dist/runtime/server/routes/well-known-directory.js +99 -0
- package/dist/runtime/server/utils/aidp-verify.d.ts +152 -0
- package/dist/runtime/server/utils/aidp-verify.js +332 -0
- package/dist/runtime/server/utils/bot-detect.d.ts +26 -0
- package/dist/runtime/server/utils/bot-detect.js +75 -0
- package/dist/runtime/server/utils/cache.d.ts +35 -0
- package/dist/runtime/server/utils/cache.js +80 -0
- package/dist/runtime/server/utils/content-registry.d.ts +3 -0
- package/dist/runtime/server/utils/content-registry.js +24 -0
- package/dist/runtime/server/utils/fetch-content.d.ts +14 -0
- package/dist/runtime/server/utils/fetch-content.js +53 -0
- package/dist/runtime/server/utils/fetch-directive.d.ts +21 -0
- package/dist/runtime/server/utils/fetch-directive.js +52 -0
- package/dist/runtime/server/utils/fetch-directory.d.ts +21 -0
- package/dist/runtime/server/utils/fetch-directory.js +59 -0
- package/dist/runtime/server/utils/hmac-verify.d.ts +37 -0
- package/dist/runtime/server/utils/hmac-verify.js +63 -0
- package/dist/runtime/server/utils/impression-queue.d.ts +33 -0
- package/dist/runtime/server/utils/impression-queue.js +145 -0
- package/dist/runtime/server/utils/query.d.ts +14 -0
- package/dist/runtime/server/utils/query.js +33 -0
- package/dist/runtime/version.d.ts +2 -0
- package/dist/runtime/version.js +2 -0
- package/package.json +62 -0
- package/src/components/AidpContent.astro +23 -0
- package/src/components/AidpLinks.astro +10 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Buffered queue for AI-crawler impressions observed by the SDK
|
|
2
|
+
// middleware. Flushes to the SpeakSpec server's
|
|
3
|
+
// POST {endpoint}/api/v1/impressions endpoint in batches.
|
|
4
|
+
//
|
|
5
|
+
// Design contract:
|
|
6
|
+
// - Fire-and-forget: enqueue() returns immediately, flush runs async
|
|
7
|
+
// - Bounded memory: maxQueueBytes hard cap (drops oldest on overrun)
|
|
8
|
+
// - Backoff on failure: N consecutive flush errors → pause 5 min
|
|
9
|
+
// - Fallback on permanent failure: emit each impression to stdout so
|
|
10
|
+
// the customer's log pipeline still sees it
|
|
11
|
+
//
|
|
12
|
+
// The queue is module-scoped — one instance per host process. In
|
|
13
|
+
// serverless deployments with cold starts, items in flight may be
|
|
14
|
+
// lost (acceptable per fire-and-forget design).
|
|
15
|
+
import { Buffer } from 'node:buffer';
|
|
16
|
+
import { SDK_USER_AGENT } from '../../version';
|
|
17
|
+
const DEFAULT_CONFIG = {
|
|
18
|
+
batchSize: 50,
|
|
19
|
+
flushIntervalMs: 60_000,
|
|
20
|
+
maxQueueBytes: 2 * 1024 * 1024,
|
|
21
|
+
onError: 'fallback-stdout',
|
|
22
|
+
};
|
|
23
|
+
const BACKOFF_THRESHOLD = 5;
|
|
24
|
+
const BACKOFF_DURATION_MS = 5 * 60_000;
|
|
25
|
+
let state = newState();
|
|
26
|
+
let activeConfig = null;
|
|
27
|
+
function newState() {
|
|
28
|
+
return { items: [], bytes: 0, consecutiveFailures: 0, backoffUntil: 0, flushTimer: null };
|
|
29
|
+
}
|
|
30
|
+
export function configureQueue(cfg) {
|
|
31
|
+
activeConfig = { ...DEFAULT_CONFIG, ...cfg };
|
|
32
|
+
}
|
|
33
|
+
export function resetQueue() {
|
|
34
|
+
if (state.flushTimer)
|
|
35
|
+
clearTimeout(state.flushTimer);
|
|
36
|
+
state = newState();
|
|
37
|
+
activeConfig = null;
|
|
38
|
+
}
|
|
39
|
+
export function enqueueImpression(impression) {
|
|
40
|
+
if (!activeConfig) {
|
|
41
|
+
fallbackLog(impression, 'fallback-stdout', console.log);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const cfg = activeConfig;
|
|
45
|
+
const size = approximateSize(impression);
|
|
46
|
+
while (state.bytes + size > cfg.maxQueueBytes && state.items.length > 0) {
|
|
47
|
+
const dropped = state.items.shift();
|
|
48
|
+
state.bytes -= approximateSize(dropped);
|
|
49
|
+
fallbackLog(dropped, cfg.onError, cfg.logger ?? console.log);
|
|
50
|
+
}
|
|
51
|
+
state.items.push(impression);
|
|
52
|
+
state.bytes += size;
|
|
53
|
+
if (state.items.length >= cfg.batchSize) {
|
|
54
|
+
void flushQueue();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
scheduleFlush(cfg);
|
|
58
|
+
}
|
|
59
|
+
function scheduleFlush(cfg) {
|
|
60
|
+
if (state.flushTimer)
|
|
61
|
+
return;
|
|
62
|
+
state.flushTimer = setTimeout(() => {
|
|
63
|
+
state.flushTimer = null;
|
|
64
|
+
void flushQueue();
|
|
65
|
+
}, cfg.flushIntervalMs);
|
|
66
|
+
if (typeof state.flushTimer === 'object' && state.flushTimer && typeof state.flushTimer.unref === 'function') {
|
|
67
|
+
state.flushTimer.unref();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function flushQueue() {
|
|
71
|
+
if (!activeConfig || state.items.length === 0)
|
|
72
|
+
return;
|
|
73
|
+
if (state.flushTimer) {
|
|
74
|
+
clearTimeout(state.flushTimer);
|
|
75
|
+
state.flushTimer = null;
|
|
76
|
+
}
|
|
77
|
+
const cfg = activeConfig;
|
|
78
|
+
if (Date.now() < state.backoffUntil) {
|
|
79
|
+
drainTo(cfg, cfg.onError);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const batch = state.items;
|
|
83
|
+
state.items = [];
|
|
84
|
+
state.bytes = 0;
|
|
85
|
+
const payload = {
|
|
86
|
+
impressions: batch.map(i => ({
|
|
87
|
+
user_agent: i.user_agent,
|
|
88
|
+
path: i.path,
|
|
89
|
+
content_id: i.content_id,
|
|
90
|
+
client_ip: i.client_ip,
|
|
91
|
+
})),
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
const fetcher = cfg.fetcher ?? fetch;
|
|
95
|
+
const url = `${stripTrailingSlash(cfg.endpoint)}/api/v1/impressions`;
|
|
96
|
+
const ctrl = new AbortController();
|
|
97
|
+
const timeout = setTimeout(() => ctrl.abort(), 5000);
|
|
98
|
+
const res = await fetcher(url, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: {
|
|
101
|
+
'content-type': 'application/json',
|
|
102
|
+
'accept': 'application/json',
|
|
103
|
+
'user-agent': SDK_USER_AGENT,
|
|
104
|
+
'x-api-key': cfg.apiKey,
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify(payload),
|
|
107
|
+
signal: ctrl.signal,
|
|
108
|
+
});
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new Error(`POST impressions → ${res.status} ${res.statusText}`);
|
|
112
|
+
}
|
|
113
|
+
state.consecutiveFailures = 0;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
state.consecutiveFailures += 1;
|
|
117
|
+
if (state.consecutiveFailures >= BACKOFF_THRESHOLD) {
|
|
118
|
+
state.backoffUntil = Date.now() + BACKOFF_DURATION_MS;
|
|
119
|
+
}
|
|
120
|
+
for (const item of batch) {
|
|
121
|
+
fallbackLog(item, cfg.onError, cfg.logger ?? console.log);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function drainTo(cfg, mode) {
|
|
126
|
+
const items = state.items;
|
|
127
|
+
state.items = [];
|
|
128
|
+
state.bytes = 0;
|
|
129
|
+
for (const i of items)
|
|
130
|
+
fallbackLog(i, mode, cfg.logger ?? console.log);
|
|
131
|
+
}
|
|
132
|
+
function fallbackLog(impression, mode, logger) {
|
|
133
|
+
if (mode === 'silent')
|
|
134
|
+
return;
|
|
135
|
+
logger(JSON.stringify(impression));
|
|
136
|
+
}
|
|
137
|
+
function approximateSize(record) {
|
|
138
|
+
return Buffer.byteLength(JSON.stringify(record), 'utf8');
|
|
139
|
+
}
|
|
140
|
+
function stripTrailingSlash(s) {
|
|
141
|
+
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
142
|
+
}
|
|
143
|
+
export function _peekQueue() {
|
|
144
|
+
return { count: state.items.length, bytes: state.bytes, consecutiveFailures: state.consecutiveFailures, backoffUntil: state.backoffUntil };
|
|
145
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class QueryError extends Error {
|
|
2
|
+
statusCode: number;
|
|
3
|
+
statusMessage: string;
|
|
4
|
+
constructor(statusCode: number, statusMessage: string);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse an optional positive-integer query value (>= 1). Returns
|
|
8
|
+
* undefined when the input is unset; throws QueryError(400) for
|
|
9
|
+
* arrays, non-integers, zero, or negative numbers.
|
|
10
|
+
*
|
|
11
|
+
* `page=0` is rejected (the server normalises it but a fast-fail at
|
|
12
|
+
* the SDK layer surfaces the customer's mistake immediately).
|
|
13
|
+
*/
|
|
14
|
+
export declare function parsePositiveInt(value: unknown, name: string): number | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Pure helpers for parsing inbound query-string params on the AIDP
|
|
2
|
+
// route handlers. Framework-agnostic — throws QueryError that route
|
|
3
|
+
// factories convert into a Response.
|
|
4
|
+
export class QueryError extends Error {
|
|
5
|
+
statusCode;
|
|
6
|
+
statusMessage;
|
|
7
|
+
constructor(statusCode, statusMessage) {
|
|
8
|
+
super(statusMessage);
|
|
9
|
+
this.name = 'QueryError';
|
|
10
|
+
this.statusCode = statusCode;
|
|
11
|
+
this.statusMessage = statusMessage;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Parse an optional positive-integer query value (>= 1). Returns
|
|
16
|
+
* undefined when the input is unset; throws QueryError(400) for
|
|
17
|
+
* arrays, non-integers, zero, or negative numbers.
|
|
18
|
+
*
|
|
19
|
+
* `page=0` is rejected (the server normalises it but a fast-fail at
|
|
20
|
+
* the SDK layer surfaces the customer's mistake immediately).
|
|
21
|
+
*/
|
|
22
|
+
export function parsePositiveInt(value, name) {
|
|
23
|
+
if (value === undefined || value === null || value === '')
|
|
24
|
+
return undefined;
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
throw new QueryError(400, `${name} must be a single value`);
|
|
27
|
+
}
|
|
28
|
+
const n = Number(value);
|
|
29
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
|
|
30
|
+
throw new QueryError(400, `${name} must be a positive integer (>= 1)`);
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@speakspec/astro",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "AIDP 0.3 publishing channel for Astro 5 — exposes /.well-known/aidp.json and friends, fetches signed content + pointer payloads from SpeakSpec, receives §8.10 cache-invalidation webhooks.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/speakspec/astro.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://docs.speakspec.com/developer/sdk-astro",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/speakspec/astro/issues"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public",
|
|
16
|
+
"registry": "https://registry.npmjs.org/"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./middleware": {
|
|
25
|
+
"types": "./dist/middleware/index.d.ts",
|
|
26
|
+
"import": "./dist/middleware/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./components/AidpLinks.astro": "./src/components/AidpLinks.astro",
|
|
29
|
+
"./components/AidpContent.astro": "./src/components/AidpContent.astro"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src/components"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"ofetch": "^1.4.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"astro": "^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@eslint/js": "^10.0.1",
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"astro": "^5.0.0",
|
|
45
|
+
"eslint": "^9.0.0",
|
|
46
|
+
"globals": "^17.6.0",
|
|
47
|
+
"tsx": "^4.21.0",
|
|
48
|
+
"typescript": "^5.6.0",
|
|
49
|
+
"typescript-eslint": "^8.59.1",
|
|
50
|
+
"vitest": "^3.0.0"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20.0.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"generate:version": "node scripts/generate-version.mjs",
|
|
57
|
+
"build": "pnpm generate:version && tsc -p tsconfig.build.json",
|
|
58
|
+
"lint": "eslint .",
|
|
59
|
+
"test": "pnpm generate:version && vitest run --passWithNoTests",
|
|
60
|
+
"test:types": "pnpm generate:version && tsc --noEmit"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { readConfig } from '../runtime/config'
|
|
3
|
+
import { registerContent } from '../runtime/server/utils/content-registry'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
contentId: string
|
|
7
|
+
/** Path the AI crawler will hit. Required so the middleware can
|
|
8
|
+
* enrich subsequent impressions with content_id. */
|
|
9
|
+
pathname: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { contentId, pathname } = Astro.props
|
|
13
|
+
const config = readConfig()
|
|
14
|
+
const stripTrailingSlash = (s: string) => (s.endsWith('/') ? s.slice(0, -1) : s)
|
|
15
|
+
const href = config.siteOrigin && contentId
|
|
16
|
+
? `${stripTrailingSlash(config.siteOrigin)}/.well-known/aidp/content/${encodeURIComponent(contentId)}.json`
|
|
17
|
+
: null
|
|
18
|
+
|
|
19
|
+
if (pathname && contentId) {
|
|
20
|
+
registerContent(pathname, contentId)
|
|
21
|
+
}
|
|
22
|
+
---
|
|
23
|
+
{href && <link rel="aidp-content" href={href} />}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { readConfig } from '../runtime/config'
|
|
3
|
+
|
|
4
|
+
const config = readConfig()
|
|
5
|
+
const stripTrailingSlash = (s: string) => (s.endsWith('/') ? s.slice(0, -1) : s)
|
|
6
|
+
const entityHref = config.siteOrigin ? `${stripTrailingSlash(config.siteOrigin)}/.well-known/aidp.json` : null
|
|
7
|
+
const keysHref = config.endpoint ? `${stripTrailingSlash(config.endpoint)}/.well-known/aidp-keys` : null
|
|
8
|
+
---
|
|
9
|
+
{entityHref && <link rel="aidp" href={entityHref} />}
|
|
10
|
+
{keysHref && <link rel="aidp-keys" href={keysHref} />}
|