@feasibleone/blong-gogo 1.27.0 → 1.28.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/CHANGELOG.md +24 -0
- package/bin/blong-watch.ts +6 -0
- package/bin/blong.ts +52 -3
- package/package.json +8 -7
- package/src/ApiGateway.test.ts +190 -0
- package/src/ApiGateway.ts +167 -0
- package/src/BrowserLog.ts +5 -1
- package/src/Gateway.ts +9 -1
- package/src/Log.ts +33 -12
- package/src/Registry.ts +87 -7
- package/src/Remote.ts +12 -0
- package/src/Watch.ts +53 -25
- package/src/adapter/browser/http.ts +4 -2
- package/src/adapter/schema/knex/binary.ts +8 -5
- package/src/adapter/schema/knex/database.test.ts +87 -0
- package/src/adapter/schema/knex/database.ts +67 -0
- package/src/adapter/schema/knex/json.test.ts +243 -0
- package/src/adapter/schema/knex/json.ts +213 -0
- package/src/adapter/schema/knex/schemaMysql.ts +0 -26
- package/src/adapter/schema/knex/schemaTable.ts +4 -138
- package/src/adapter/schema/knex/types.ts +46 -2
- package/src/adapter/schema/knex/utils.ts +3 -1
- package/src/adapter/server/knex-deadlock.test.ts +114 -0
- package/src/adapter/server/knex.ts +425 -102
- package/src/adapter/server/redis.ts +236 -0
- package/src/adapter/server.ts +2 -0
- package/src/chain.ts +12 -1
- package/src/cliHelp.test.ts +31 -0
- package/src/cliHelp.ts +45 -0
- package/src/codec/adapter/mle/ready.ts +22 -12
- package/src/graceful-shutdown.spawn.test.ts +68 -0
- package/src/graceful-shutdown.test.ts +109 -0
- package/src/jose.ts +7 -293
- package/src/kopi.ts +77 -12
- package/src/lib.ts +40 -4
- package/src/load.ts +94 -55
- package/src/log.progress.test.ts +35 -0
- package/src/pino-cacache.mjs +7 -0
- package/src/pino-cacache.ts +13 -2
- package/src/pino-pretty.mjs +8 -0
- package/src/registry.validation.override.test.ts +203 -0
- package/src/runServer.ts +114 -2
- package/src/template-files.ts +52 -0
- package/template/browser/orchestrator/subject/init.ts +15 -0
- package/template/browser/test/test/test$Object.flow.ts +100 -0
- package/template/browser-test.ts +87 -0
- package/template/browser.ts +29 -0
- package/template/error/error.ts +11 -0
- package/template/eslint.config.mjs +2 -0
- package/template/index.browser.ts +36 -0
- package/template/index.html.ts +7 -0
- package/template/index.test.ts +32 -0
- package/template/index.ts +52 -0
- package/template/meta/db/db.ts +21 -0
- package/template/meta/dbTest/$subject$ObjectMerge.yaml +8 -0
- package/template/meta/dbTest/accessAuthorizationMerge.yaml +21 -0
- package/template/meta/model/$subject$ObjectModel.ts +134 -0
- package/template/meta/type/schema.ts +55 -0
- package/template/orchestrator/subject/init.ts +16 -0
- package/template/playwright.config.ts +7 -0
- package/template/server/test/test/test$Object.ts +83 -0
- package/template/server.ts +12 -0
- package/template/test/$subject.play.ts +64 -0
- package/template/tsconfig.json +28 -0
- package/template/vite.config.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.28.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.27.0...blong-gogo-v1.28.0) (2026-08-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* blong-dev log ([88ea53b](https://github.com/feasibleone/blong/commit/88ea53bbe411803a540c3158d151220a1c9bee26))
|
|
9
|
+
* master-detail handling ([03c5bbd](https://github.com/feasibleone/blong/commit/03c5bbd9401a82a9427577e7611f2622aadee4e5))
|
|
10
|
+
* redis adapter and blong-gateway ([c28a68f](https://github.com/feasibleone/blong/commit/c28a68f5f9cfc2398771cca05c4366efc147eb6e))
|
|
11
|
+
* resolve some AI agent frictions ([13ca27f](https://github.com/feasibleone/blong/commit/13ca27f09f0271a25ea7ad45a2ce3f73bf6710e0))
|
|
12
|
+
* resolve some AI agent frictions and improve the framework features ([23b6986](https://github.com/feasibleone/blong/commit/23b69863b458e97f1dad249fcdeec5e182a69b74))
|
|
13
|
+
* self registration ([20f3791](https://github.com/feasibleone/blong/commit/20f3791c453d0f6f4cbf28c2e3af1fbb959a8d0d))
|
|
14
|
+
* update blong-kopi and relevant skills to the latest patterns ([52dacae](https://github.com/feasibleone/blong/commit/52dacae17410933d262637de593bd8dc5db628e8))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* improve tooling ([c484c28](https://github.com/feasibleone/blong/commit/c484c28de5ec3e574297b27e49ea42365ea956c1))
|
|
20
|
+
* missing party permissions ([afeb4da](https://github.com/feasibleone/blong/commit/afeb4da93412c0d93da3b2d735471fd3e3a28b27))
|
|
21
|
+
* registry logger ([443a9ed](https://github.com/feasibleone/blong/commit/443a9ed3948a871d42a5c7ff748751ec99ee460b))
|
|
22
|
+
* remove unused functions ([3de9db3](https://github.com/feasibleone/blong/commit/3de9db31032d269cbda9b1e57bc06eec5a3a2061))
|
|
23
|
+
* schema refresh and model list handling ([920e453](https://github.com/feasibleone/blong/commit/920e453f69b9335a07d8c901d042c3d360d4c010))
|
|
24
|
+
* UI issues ([653baaf](https://github.com/feasibleone/blong/commit/653baaf796b20dd4c42ba5be9a2a8be3668e7227))
|
|
25
|
+
* update dependencies ([0f86433](https://github.com/feasibleone/blong/commit/0f86433e1777df5348643e69ecfc28d31dceac35))
|
|
26
|
+
|
|
3
27
|
## [1.27.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.26.0...blong-gogo-v1.27.0) (2026-07-23)
|
|
4
28
|
|
|
5
29
|
|
package/bin/blong-watch.ts
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
import minimist from 'minimist';
|
|
4
4
|
import {existsSync} from 'node:fs';
|
|
5
5
|
import {resolve} from 'node:path';
|
|
6
|
+
import {printUsage, shouldShowHelp} from '../src/cliHelp.ts';
|
|
6
7
|
import {autoRun} from '../src/runServer.ts';
|
|
7
8
|
|
|
8
9
|
const argv: {_: string[]} = minimist(process.argv.slice(2));
|
|
9
10
|
|
|
11
|
+
// `--help` / `-h` — short-circuit before autoRun so usage works from any directory.
|
|
12
|
+
if (shouldShowHelp(argv as Record<string, unknown>)) {
|
|
13
|
+
printUsage(() => process.exit(0));
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
// The first positional arg is an optional file/folder target; the rest are intents.
|
|
11
17
|
const [maybeTarget, ...rest] = argv._;
|
|
12
18
|
const target = maybeTarget && existsSync(resolve(maybeTarget)) ? resolve(maybeTarget) : undefined;
|
package/bin/blong.ts
CHANGED
|
@@ -3,13 +3,62 @@
|
|
|
3
3
|
import minimist from 'minimist';
|
|
4
4
|
import {existsSync} from 'node:fs';
|
|
5
5
|
import {resolve} from 'node:path';
|
|
6
|
+
import {printUsage, shouldShowHelp} from '../src/cliHelp.ts';
|
|
6
7
|
import {autoRun} from '../src/runServer.ts';
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const rawArgv = minimist(process.argv.slice(2)) as {
|
|
10
|
+
_: string[];
|
|
11
|
+
object?: string;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
};
|
|
9
14
|
|
|
15
|
+
// `--help` / `-h` — short-circuit before realm-create / autoRun so usage works
|
|
16
|
+
// from any directory (even one without a suite entry point).
|
|
17
|
+
if (shouldShowHelp(rawArgv)) {
|
|
18
|
+
printUsage(() => process.exit(0));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {_: positionals, object: objectName} = rawArgv;
|
|
22
|
+
|
|
23
|
+
// `blong realm <name>` (or `blong create realm <name>`) — scaffold a new realm.
|
|
24
|
+
// `create` is an optional (implied) intent; `realm` selects the creation path.
|
|
25
|
+
// The realm name is the positional following `realm`. This stays within the
|
|
26
|
+
// intent model — no separate subcommand — so the target-vs-intents parsing is
|
|
27
|
+
// untouched for every other invocation.
|
|
28
|
+
const isRealmCreate =
|
|
29
|
+
positionals.includes('realm') &&
|
|
30
|
+
// If one of the positionals is an existing path it is a target, not the
|
|
31
|
+
// realm-creation intent.
|
|
32
|
+
!positionals.some(p => existsSync(resolve(p)));
|
|
33
|
+
if (isRealmCreate) {
|
|
34
|
+
const realmIdx = positionals.indexOf('realm');
|
|
35
|
+
const realmName = positionals[realmIdx + 1];
|
|
36
|
+
if (!realmName || realmName === 'create' || realmName === 'realm') {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'Usage: blong realm <name> [--object <entity>] (or: blong create realm <name>)',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const {createRealm} = await import('../src/kopi.ts');
|
|
42
|
+
await createRealm(resolve(process.cwd(), realmName), undefined, {object: objectName});
|
|
43
|
+
// Strip the reserved words (and the consumed `--object` flag) so they never
|
|
44
|
+
// leak into config intents, then run the freshly scaffolded realm — autoRun
|
|
45
|
+
// finds its `index.ts` in the new folder.
|
|
46
|
+
const cleaned = positionals.filter(p => p !== 'realm' && p !== 'create' && p !== realmName);
|
|
47
|
+
const namedFlags = Object.entries(rawArgv)
|
|
48
|
+
.filter(([key]) => key !== '_' && key !== 'object')
|
|
49
|
+
.flatMap(([key, value]) => {
|
|
50
|
+
if (Array.isArray(value)) return value.map(v => `--${key}=${v}`);
|
|
51
|
+
if (value === true) return [`--${key}`];
|
|
52
|
+
return [`--${key}=${value}`];
|
|
53
|
+
});
|
|
54
|
+
process.argv = [process.argv[0], process.argv[1], ...cleaned, ...namedFlags];
|
|
55
|
+
process.chdir(resolve(process.cwd(), realmName));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parsed = minimist(process.argv.slice(2)) as {_: string[]};
|
|
10
59
|
// The first positional arg is an optional file/folder target; the rest are intents.
|
|
11
|
-
const [maybeTarget, ...rest] =
|
|
60
|
+
const [maybeTarget, ...rest] = parsed._;
|
|
12
61
|
const target = maybeTarget && existsSync(resolve(maybeTarget)) ? maybeTarget : undefined;
|
|
13
|
-
const intents = target ? rest :
|
|
62
|
+
const intents = target ? rest : parsed._;
|
|
14
63
|
|
|
15
64
|
await autoRun({cwd: process.cwd(), target, intents});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -26,15 +26,15 @@
|
|
|
26
26
|
"@fastify/cors": "^11.2.0",
|
|
27
27
|
"@fastify/deepmerge": "^3.2.1",
|
|
28
28
|
"@fastify/helmet": "^13.1.0",
|
|
29
|
-
"@fastify/static": "^10.1.
|
|
29
|
+
"@fastify/static": "^10.1.3",
|
|
30
30
|
"@fastify/swagger": "^9.7.0",
|
|
31
31
|
"@fastify/swagger-ui": "^5.2.5",
|
|
32
32
|
"@fastify/type-provider-typebox": "^6.1.0",
|
|
33
33
|
"@feasibleone/blong": "^1.0.0",
|
|
34
34
|
"@feasibleone/blong-chain": "^1.0.0",
|
|
35
35
|
"@feasibleone/blong-config": "^1.0.0",
|
|
36
|
-
"@feasibleone/blong-kopi": "^1.0.0",
|
|
37
36
|
"@feasibleone/blong-lib": "^1.0.0",
|
|
37
|
+
"@feasibleone/blong-mle": "^1.0.0",
|
|
38
38
|
"@feasibleone/blong-mock": "^1.0.0",
|
|
39
39
|
"@keycloak/keycloak-admin-client": "^26.6.2",
|
|
40
40
|
"@kubernetes/client-node": "^1.4.0",
|
|
@@ -45,9 +45,10 @@
|
|
|
45
45
|
"browser-process-hrtime": "^1.0.0",
|
|
46
46
|
"cacache": "^20.0.4",
|
|
47
47
|
"chokidar": "^5.0.0",
|
|
48
|
-
"fastify": "^5.
|
|
48
|
+
"fastify": "^5.11.3",
|
|
49
49
|
"fastify-plugin": "^5.1.0",
|
|
50
50
|
"got": "^14.6.6",
|
|
51
|
+
"ioredis": "^6.0.0",
|
|
51
52
|
"jose": "^6.2.1",
|
|
52
53
|
"knex": "^3.2.10",
|
|
53
54
|
"ky": "^1.14.2",
|
|
@@ -67,13 +68,13 @@
|
|
|
67
68
|
"tinyglobby": "^0.2.16",
|
|
68
69
|
"typebox": "^1.1.5",
|
|
69
70
|
"ulidx": "^2.4.1",
|
|
70
|
-
"undici": "^8.0
|
|
71
|
+
"undici": "^8.10.0",
|
|
71
72
|
"ut-bitsyntax": "^6.2.7",
|
|
72
73
|
"ut-dns-discovery": "^6.2.3",
|
|
73
74
|
"ut-function.cbc": "^1.1.6",
|
|
74
75
|
"ut-function.interpolate": "1.1.3",
|
|
75
76
|
"ut-function.merge": "^1.5.6",
|
|
76
|
-
"uuid": "^13.0.
|
|
77
|
+
"uuid": "^13.0.2",
|
|
77
78
|
"yaml": "^2.8.3",
|
|
78
79
|
"@feasibleone/blong-template": "1.0.0"
|
|
79
80
|
},
|
|
@@ -86,7 +87,7 @@
|
|
|
86
87
|
"playwright": "^1.60.0",
|
|
87
88
|
"tap": "^21.6.3",
|
|
88
89
|
"typescript": "^6.0.3",
|
|
89
|
-
"@feasibleone/blong-dev": "1.
|
|
90
|
+
"@feasibleone/blong-dev": "1.2.0"
|
|
90
91
|
},
|
|
91
92
|
"scripts": {
|
|
92
93
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ApiGateway metering plugin — header injection, blocking status
|
|
3
|
+
* codes, fail-closed 503, and passthrough for non-metered routes — via Fastify
|
|
4
|
+
* injection (modeled on RestFs.test.ts).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import {after, describe, it} from 'node:test';
|
|
9
|
+
|
|
10
|
+
import fastify, {type FastifyInstance} from 'fastify';
|
|
11
|
+
|
|
12
|
+
import ApiGateway from './ApiGateway.ts';
|
|
13
|
+
|
|
14
|
+
type MeterFn = (params: {bundle: string; creditCost: number}, $meta: unknown) => Promise<unknown>;
|
|
15
|
+
|
|
16
|
+
interface SetupResult {
|
|
17
|
+
server: FastifyInstance;
|
|
18
|
+
cleanup: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a Fastify instance with the ApiGateway plugin registered plus a
|
|
23
|
+
* metered route, a non-metered route and an opted-out route.
|
|
24
|
+
*/
|
|
25
|
+
async function setup(
|
|
26
|
+
meterFn: MeterFn,
|
|
27
|
+
opts?: {handlerUnavailable?: boolean},
|
|
28
|
+
): Promise<SetupResult> {
|
|
29
|
+
let capturedPlugin: Parameters<FastifyInstance['register']>[0] | null = null;
|
|
30
|
+
let capturedOptions: Parameters<FastifyInstance['register']>[1] | null = null;
|
|
31
|
+
const fakeGateway = {
|
|
32
|
+
registerPlugin(plugin: typeof capturedPlugin, opts: typeof capturedOptions) {
|
|
33
|
+
capturedPlugin = plugin;
|
|
34
|
+
capturedOptions = opts;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const fakeLocal = {
|
|
38
|
+
get(_name: string): {method: (...args: unknown[]) => Promise<unknown[]>} | undefined {
|
|
39
|
+
if (opts?.handlerUnavailable) return undefined;
|
|
40
|
+
return {
|
|
41
|
+
method: async (...args: unknown[]) => [
|
|
42
|
+
await meterFn(args[0] as {bundle: string; creditCost: number}, args[1]),
|
|
43
|
+
undefined,
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const apiGateway = new ApiGateway(
|
|
50
|
+
{
|
|
51
|
+
enabled: true,
|
|
52
|
+
meterHandler: 'gateway.meter.check',
|
|
53
|
+
statusCodes: {rate: 429, credits: 429, subscription: 403},
|
|
54
|
+
},
|
|
55
|
+
{gateway: fakeGateway as never, local: fakeLocal as never},
|
|
56
|
+
);
|
|
57
|
+
await apiGateway.init();
|
|
58
|
+
|
|
59
|
+
const server = fastify();
|
|
60
|
+
server.addHook('preValidation', (_req, _reply, done) => done());
|
|
61
|
+
if (capturedPlugin) await server.register(capturedPlugin, capturedOptions!);
|
|
62
|
+
|
|
63
|
+
server.route({
|
|
64
|
+
method: 'POST',
|
|
65
|
+
url: '/rpc/vision/compute',
|
|
66
|
+
config: {auth: 'jwt', bundle: 'Vision AI', creditCost: 5},
|
|
67
|
+
handler: async () => ({success: true, vision: 'computed'}),
|
|
68
|
+
});
|
|
69
|
+
server.route({
|
|
70
|
+
method: 'POST',
|
|
71
|
+
url: '/rpc/plain/echo',
|
|
72
|
+
handler: async () => ({ok: true}),
|
|
73
|
+
});
|
|
74
|
+
server.route({
|
|
75
|
+
method: 'POST',
|
|
76
|
+
url: '/rpc/opt/out',
|
|
77
|
+
config: {auth: 'jwt', bundle: 'Vision AI', creditCost: 5, meter: false},
|
|
78
|
+
handler: async () => ({ok: true}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await server.ready();
|
|
82
|
+
return {server, cleanup: () => server.close()};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const okDecision = {
|
|
86
|
+
allowed: true,
|
|
87
|
+
reason: 'ok',
|
|
88
|
+
creditsRemaining: 995,
|
|
89
|
+
rateLimit: 100,
|
|
90
|
+
rateCount: 1,
|
|
91
|
+
rateResetAt: 123456,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
describe('ApiGateway metering plugin', () => {
|
|
95
|
+
after(() => {});
|
|
96
|
+
|
|
97
|
+
it('allows a metered route and injects the rate/credit headers', async () => {
|
|
98
|
+
const {server, cleanup} = await setup(async () => okDecision);
|
|
99
|
+
try {
|
|
100
|
+
const res = await server.inject({method: 'POST', url: '/rpc/vision/compute'});
|
|
101
|
+
assert.strictEqual(res.statusCode, 200);
|
|
102
|
+
// Fastify lowercases header names: X-RateLimit-Limit → x-ratelimit-limit
|
|
103
|
+
assert.strictEqual(res.headers['x-ratelimit-limit'], '100');
|
|
104
|
+
assert.strictEqual(res.headers['x-ratelimit-remaining'], '99');
|
|
105
|
+
assert.strictEqual(res.headers['x-credits-remaining'], '995');
|
|
106
|
+
assert.deepStrictEqual(res.json(), {success: true, vision: 'computed'});
|
|
107
|
+
} finally {
|
|
108
|
+
await cleanup();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('blocks with 429 when the meter denies (rate limit)', async () => {
|
|
113
|
+
const {server, cleanup} = await setup(async () => ({
|
|
114
|
+
allowed: false,
|
|
115
|
+
reason: 'rate',
|
|
116
|
+
creditsRemaining: -1,
|
|
117
|
+
rateLimit: 100,
|
|
118
|
+
rateCount: 101,
|
|
119
|
+
rateResetAt: 9999,
|
|
120
|
+
}));
|
|
121
|
+
try {
|
|
122
|
+
const res = await server.inject({method: 'POST', url: '/rpc/vision/compute'});
|
|
123
|
+
assert.strictEqual(res.statusCode, 429);
|
|
124
|
+
assert.strictEqual(res.headers['retry-after'], '1');
|
|
125
|
+
assert.strictEqual((res.json() as {error: string}).error, 'rate');
|
|
126
|
+
} finally {
|
|
127
|
+
await cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('blocks with 403 when the subscription is missing', async () => {
|
|
132
|
+
const {server, cleanup} = await setup(async () => ({
|
|
133
|
+
allowed: false,
|
|
134
|
+
reason: 'subscription',
|
|
135
|
+
creditsRemaining: -1,
|
|
136
|
+
}));
|
|
137
|
+
try {
|
|
138
|
+
const res = await server.inject({method: 'POST', url: '/rpc/vision/compute'});
|
|
139
|
+
assert.strictEqual(res.statusCode, 403);
|
|
140
|
+
assert.strictEqual((res.json() as {error: string}).error, 'subscription');
|
|
141
|
+
} finally {
|
|
142
|
+
await cleanup();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns 503 (fail-closed) when the meter handler throws', async () => {
|
|
147
|
+
const {server, cleanup} = await setup(async () => {
|
|
148
|
+
throw new Error('Redis unavailable');
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
const res = await server.inject({method: 'POST', url: '/rpc/vision/compute'});
|
|
152
|
+
assert.strictEqual(res.statusCode, 503);
|
|
153
|
+
assert.strictEqual((res.json() as {error: string}).error, 'Service Unavailable');
|
|
154
|
+
} finally {
|
|
155
|
+
await cleanup();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns 503 (fail-closed) when the meter handler is unavailable', async () => {
|
|
160
|
+
const {server, cleanup} = await setup(async () => okDecision, {handlerUnavailable: true});
|
|
161
|
+
try {
|
|
162
|
+
const res = await server.inject({method: 'POST', url: '/rpc/vision/compute'});
|
|
163
|
+
assert.strictEqual(res.statusCode, 503);
|
|
164
|
+
} finally {
|
|
165
|
+
await cleanup();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('passes through routes without a bundle config', async () => {
|
|
170
|
+
const {server, cleanup} = await setup(async () => okDecision);
|
|
171
|
+
try {
|
|
172
|
+
const res = await server.inject({method: 'POST', url: '/rpc/plain/echo'});
|
|
173
|
+
assert.strictEqual(res.statusCode, 200);
|
|
174
|
+
assert.deepStrictEqual(res.json(), {ok: true});
|
|
175
|
+
} finally {
|
|
176
|
+
await cleanup();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('passes through routes opted out with meter: false', async () => {
|
|
181
|
+
const {server, cleanup} = await setup(async () => okDecision);
|
|
182
|
+
try {
|
|
183
|
+
const res = await server.inject({method: 'POST', url: '/rpc/opt/out'});
|
|
184
|
+
assert.strictEqual(res.statusCode, 200);
|
|
185
|
+
assert.deepStrictEqual(res.json(), {ok: true});
|
|
186
|
+
} finally {
|
|
187
|
+
await cleanup();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type {IGateway, ILocal, ILog} from '@feasibleone/blong/types';
|
|
2
|
+
import {Internal} from '@feasibleone/blong/types';
|
|
3
|
+
import type {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify';
|
|
4
|
+
import fp from 'fastify-plugin';
|
|
5
|
+
|
|
6
|
+
interface IConfig {
|
|
7
|
+
/**
|
|
8
|
+
* When false (default) the plugin registers nothing and is a no-op.
|
|
9
|
+
*/
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Handler (semantic triple) that performs metering for a request.
|
|
13
|
+
* It must accept `{bundle, creditCost}` and return
|
|
14
|
+
* `{allowed, reason, creditsRemaining, rateLimit, rateCount, rateResetAt}`.
|
|
15
|
+
*/
|
|
16
|
+
meterHandler: string;
|
|
17
|
+
/**
|
|
18
|
+
* HTTP status codes for each blocking reason.
|
|
19
|
+
*/
|
|
20
|
+
statusCodes: {
|
|
21
|
+
rate: number;
|
|
22
|
+
credits: number;
|
|
23
|
+
subscription: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface IGatewayWithPlugins extends IGateway {
|
|
28
|
+
registerPlugin(plugin: unknown, options?: unknown): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface IApiRef {
|
|
32
|
+
log?: ILog;
|
|
33
|
+
gateway?: IGatewayWithPlugins;
|
|
34
|
+
local?: ILocal;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface IMeterDecision {
|
|
38
|
+
allowed: boolean;
|
|
39
|
+
reason: 'ok' | 'rate' | 'credits' | 'subscription';
|
|
40
|
+
creditsRemaining?: number;
|
|
41
|
+
rateLimit?: number;
|
|
42
|
+
rateCount?: number;
|
|
43
|
+
rateResetAt?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface IRouteConfig {
|
|
47
|
+
bundle?: string;
|
|
48
|
+
creditCost?: number;
|
|
49
|
+
meter?: boolean;
|
|
50
|
+
methodName?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default class ApiGateway extends Internal {
|
|
54
|
+
#config: IConfig = {
|
|
55
|
+
enabled: false,
|
|
56
|
+
meterHandler: 'gateway.meter.check',
|
|
57
|
+
statusCodes: {
|
|
58
|
+
rate: 429,
|
|
59
|
+
credits: 429,
|
|
60
|
+
subscription: 403,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
#apiRef: IApiRef;
|
|
65
|
+
|
|
66
|
+
public constructor(config: IConfig, apiRef: IApiRef) {
|
|
67
|
+
super({log: apiRef.log});
|
|
68
|
+
this.merge(this.#config, config);
|
|
69
|
+
this.#apiRef = apiRef;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public async init(): Promise<void> {
|
|
73
|
+
if (!this.#config.enabled || !this.#apiRef.gateway) return;
|
|
74
|
+
|
|
75
|
+
const config = this.#config;
|
|
76
|
+
const apiRef = this.#apiRef;
|
|
77
|
+
const [subject] = config.meterHandler.split('.');
|
|
78
|
+
const reqName = `ports.${subject}.request`;
|
|
79
|
+
|
|
80
|
+
const plugin = fp(
|
|
81
|
+
async (server: FastifyInstance) => {
|
|
82
|
+
// Runs AFTER the jwt plugin (auth + authorize). Metering is the
|
|
83
|
+
// last gate before the route handler — it does NOT authorize.
|
|
84
|
+
server.addHook(
|
|
85
|
+
'preHandler',
|
|
86
|
+
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
87
|
+
const routeConfig = request.routeOptions?.config as
|
|
88
|
+
| IRouteConfig
|
|
89
|
+
| undefined;
|
|
90
|
+
// Opt-in per route: only routes that declare a `bundle`
|
|
91
|
+
// are metered. `meter: false` opts a route out.
|
|
92
|
+
if (!routeConfig || routeConfig.bundle === undefined) return;
|
|
93
|
+
if (routeConfig.meter === false) return;
|
|
94
|
+
|
|
95
|
+
const handler = apiRef.local?.get(reqName);
|
|
96
|
+
if (!handler) {
|
|
97
|
+
request.log.error(
|
|
98
|
+
{meterHandler: config.meterHandler},
|
|
99
|
+
'gateway metering handler unavailable',
|
|
100
|
+
);
|
|
101
|
+
return reply.code(503).send({error: 'Service Unavailable'});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let result: IMeterDecision;
|
|
105
|
+
try {
|
|
106
|
+
const [res] = (await handler.method(
|
|
107
|
+
{
|
|
108
|
+
bundle: routeConfig.bundle,
|
|
109
|
+
creditCost: routeConfig.creditCost ?? 0,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
method: config.meterHandler,
|
|
113
|
+
mtid: 'request',
|
|
114
|
+
auth: request.auth?.credentials,
|
|
115
|
+
},
|
|
116
|
+
)) as [IMeterDecision, unknown];
|
|
117
|
+
result = res;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// Fail-closed: any metering error (e.g. Redis down)
|
|
120
|
+
// blocks the request with 503.
|
|
121
|
+
request.log.error({err: error}, 'gateway metering error');
|
|
122
|
+
return reply.code(503).send({error: 'Service Unavailable'});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (result && typeof result === 'object') {
|
|
126
|
+
if (result.rateLimit !== undefined)
|
|
127
|
+
reply.header('X-RateLimit-Limit', String(result.rateLimit));
|
|
128
|
+
if (result.rateLimit !== undefined && result.rateCount !== undefined) {
|
|
129
|
+
reply.header(
|
|
130
|
+
'X-RateLimit-Remaining',
|
|
131
|
+
String(Math.max(0, result.rateLimit - result.rateCount)),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (result.creditsRemaining !== undefined)
|
|
135
|
+
reply.header(
|
|
136
|
+
'X-Credits-Remaining',
|
|
137
|
+
String(result.creditsRemaining),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!result || result.allowed === false) {
|
|
142
|
+
const reason = result?.reason ?? 'subscription';
|
|
143
|
+
const status =
|
|
144
|
+
(config.statusCodes as Record<string, number>)[reason] ?? 429;
|
|
145
|
+
if (result?.rateResetAt)
|
|
146
|
+
reply.header(
|
|
147
|
+
'Retry-After',
|
|
148
|
+
String(
|
|
149
|
+
Math.max(
|
|
150
|
+
1,
|
|
151
|
+
Math.ceil(result.rateResetAt - Date.now() / 1000),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
return reply
|
|
156
|
+
.code(status)
|
|
157
|
+
.send({error: reason, message: `request ${reason} blocked`});
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
{name: 'api-gateway'},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
apiRef.gateway!.registerPlugin(plugin);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/BrowserLog.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
2
|
+
import {withProgress} from '@feasibleone/blong-lib';
|
|
2
3
|
import type {Level, Logger as PinoLogger} from 'pino';
|
|
3
4
|
|
|
4
5
|
// ── level constants (bunyan numeric levels) ──────────────────────────────────
|
|
@@ -207,6 +208,9 @@ export default class BrowserLog extends Internal implements ILog {
|
|
|
207
208
|
case 'fatal':
|
|
208
209
|
result.fatal = child.fatal.bind(child) as ILogger['fatal'];
|
|
209
210
|
}
|
|
210
|
-
return
|
|
211
|
+
return {
|
|
212
|
+
...result,
|
|
213
|
+
progress: (label, promise, options) => withProgress(result, label, promise, options),
|
|
214
|
+
};
|
|
211
215
|
}
|
|
212
216
|
}
|
package/src/Gateway.ts
CHANGED
|
@@ -156,6 +156,7 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
156
156
|
#server: ReturnType<typeof fastify> | null = null;
|
|
157
157
|
#resolution: IResolution;
|
|
158
158
|
#log: ILog;
|
|
159
|
+
#logger: ReturnType<ILog['child']> | undefined;
|
|
159
160
|
#config: IConfig = {
|
|
160
161
|
host: '0.0.0.0',
|
|
161
162
|
port: 8080,
|
|
@@ -365,6 +366,9 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
365
366
|
config: {
|
|
366
367
|
auth: value.auth ?? 'jwt',
|
|
367
368
|
methodName: method,
|
|
369
|
+
...(value.bundle !== undefined && {bundle: value.bundle}),
|
|
370
|
+
...(value.creditCost !== undefined && {creditCost: value.creditCost}),
|
|
371
|
+
...(value.meter !== undefined && {meter: value.meter}),
|
|
368
372
|
},
|
|
369
373
|
schema: Type && {
|
|
370
374
|
...('body' in value
|
|
@@ -558,9 +562,10 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
558
562
|
|
|
559
563
|
public async start(): Promise<IGateway> {
|
|
560
564
|
const old = this.#server;
|
|
565
|
+
this.#logger = this.#log.child({name: 'gateway'}, {level: this.#config.logLevel});
|
|
561
566
|
try {
|
|
562
567
|
this.#server = fastify({
|
|
563
|
-
loggerInstance: this.#
|
|
568
|
+
loggerInstance: this.#logger,
|
|
564
569
|
forceCloseConnections: true,
|
|
565
570
|
ajv: {
|
|
566
571
|
customOptions: {
|
|
@@ -652,6 +657,9 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
652
657
|
if (this.#manifest && address && typeof address === 'object') {
|
|
653
658
|
this.#manifest.gatewayPort = address.port;
|
|
654
659
|
}
|
|
660
|
+
|
|
661
|
+
if (!this.#config.port && !['info', 'debug', 'trace'].includes(this.#config.logLevel ?? ''))
|
|
662
|
+
this.#logger?.warn?.(`gateway listening at random port ${address.port}`);
|
|
655
663
|
return this;
|
|
656
664
|
}
|
|
657
665
|
|
package/src/Log.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {withProgress} from '@feasibleone/blong-lib';
|
|
1
2
|
import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
2
|
-
import {pino, type
|
|
3
|
+
import {pino, type Logger, type LoggerOptions} from 'pino';
|
|
3
4
|
import {monotonicFactory} from 'ulidx';
|
|
4
5
|
import type {CacacheTransportOptions} from './pino-cacache.js';
|
|
5
6
|
|
|
@@ -28,14 +29,31 @@ const ignoreArgPatterns = [
|
|
|
28
29
|
// execArgv to [], stripping the TypeScript loader. We work around this by
|
|
29
30
|
// explicitly forwarding process.execArgv so the TypeScript loader is available
|
|
30
31
|
// inside the transport worker thread.
|
|
32
|
+
//
|
|
33
|
+
// However, when the process is started under `tap` (e.g. `blong-dev test`),
|
|
34
|
+
// process.execArgv contains tap's own `--import` hooks (@tapjs/typescript →
|
|
35
|
+
// pirates/ts-node). Those hooks intercept `.ts` loads in the worker and bypass
|
|
36
|
+
// Node's native type stripping, which fails on `import type` constructs
|
|
37
|
+
// (SyntaxError: Unexpected identifier 'PinoPretty' in pino-pretty.ts). So we
|
|
38
|
+
// drop tap's hooks from the worker execArgv.
|
|
39
|
+
//
|
|
40
|
+
// Dropping the hooks alone is not enough: pino's multi-target transport loads
|
|
41
|
+
// `.ts`/`.cts` targets with `realRequire()` (CJS require, relying on ts-node),
|
|
42
|
+
// which fails regardless of execArgv. We therefore point both transports at
|
|
43
|
+
// tiny `.mjs` shims (pino-pretty.mjs / pino-cacache.mjs) that re-export the
|
|
44
|
+
// `.ts` modules. Pino then treats them as non-TypeScript targets and loads
|
|
45
|
+
// them with `realImport()` (dynamic ESM import), where Node's native type
|
|
46
|
+
// stripping handles the re-exported `.ts` — in both tap and normal dev runs.
|
|
31
47
|
const WORKER_OPTS = {
|
|
32
48
|
execArgv: process.execArgv.filter(
|
|
33
|
-
arg =>
|
|
49
|
+
arg =>
|
|
50
|
+
!ignoreArgPatterns.some(pattern => arg.startsWith(pattern)) &&
|
|
51
|
+
!(arg.startsWith('--import=') && arg.includes('@tapjs')),
|
|
34
52
|
),
|
|
35
53
|
};
|
|
36
54
|
|
|
37
55
|
const PRETTY_TRANSPORT = {
|
|
38
|
-
target: './pino-pretty.
|
|
56
|
+
target: './pino-pretty.mjs',
|
|
39
57
|
worker: WORKER_OPTS,
|
|
40
58
|
options: {
|
|
41
59
|
singleLine: true,
|
|
@@ -77,7 +95,7 @@ export default class Log extends Internal implements ILog {
|
|
|
77
95
|
targets: [
|
|
78
96
|
PRETTY_TRANSPORT,
|
|
79
97
|
{
|
|
80
|
-
target: './pino-cacache.
|
|
98
|
+
target: './pino-cacache.mjs',
|
|
81
99
|
worker: WORKER_OPTS,
|
|
82
100
|
options: cacacheOptions,
|
|
83
101
|
},
|
|
@@ -99,13 +117,13 @@ export default class Log extends Internal implements ILog {
|
|
|
99
117
|
bindings: object,
|
|
100
118
|
): ReturnType<ILog['logger']> {
|
|
101
119
|
const child = this.#logger.child(bindings, {level});
|
|
102
|
-
const result:
|
|
103
|
-
trace:
|
|
104
|
-
debug:
|
|
105
|
-
info:
|
|
106
|
-
warn:
|
|
107
|
-
error:
|
|
108
|
-
fatal:
|
|
120
|
+
const result: ILogger = {
|
|
121
|
+
trace: undefined,
|
|
122
|
+
debug: undefined,
|
|
123
|
+
info: undefined,
|
|
124
|
+
warn: undefined,
|
|
125
|
+
error: undefined,
|
|
126
|
+
fatal: undefined,
|
|
109
127
|
};
|
|
110
128
|
switch (level) {
|
|
111
129
|
case 'trace':
|
|
@@ -121,6 +139,9 @@ export default class Log extends Internal implements ILog {
|
|
|
121
139
|
case 'fatal':
|
|
122
140
|
result.fatal = child.fatal.bind(child);
|
|
123
141
|
}
|
|
124
|
-
return
|
|
142
|
+
return {
|
|
143
|
+
...result,
|
|
144
|
+
progress: (label, promise, options) => withProgress(result, label, promise, options),
|
|
145
|
+
};
|
|
125
146
|
}
|
|
126
147
|
}
|