@jskit-ai/kernel 0.1.187 → 0.1.189
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/package.json
CHANGED
package/server/http/index.js
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function isJsonValue(value, ancestors = new Set()) {
|
|
2
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
3
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
4
|
+
if (typeof value !== "object" || ancestors.has(value)) return false;
|
|
5
|
+
|
|
6
|
+
const array = Array.isArray(value);
|
|
7
|
+
const prototype = Object.getPrototypeOf(value);
|
|
8
|
+
if (prototype !== (array ? Array.prototype : Object.prototype) && !(prototype === null && !array)) return false;
|
|
9
|
+
|
|
10
|
+
const keys = Reflect.ownKeys(value);
|
|
11
|
+
if (array && (keys.length !== value.length + 1 || keys.some((key, index) =>
|
|
12
|
+
key !== (index === value.length ? "length" : String(index))
|
|
13
|
+
))) return false;
|
|
14
|
+
|
|
15
|
+
ancestors.add(value);
|
|
16
|
+
const json = keys.every((key) => {
|
|
17
|
+
if (array && key === "length") return true;
|
|
18
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
19
|
+
return typeof key === "string" && property.enumerable && Object.hasOwn(property, "value") &&
|
|
20
|
+
isJsonValue(property.value, ancestors);
|
|
21
|
+
});
|
|
22
|
+
ancestors.delete(value);
|
|
23
|
+
return json;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function schemaCacheKey(schema) {
|
|
27
|
+
try {
|
|
28
|
+
return isJsonValue(schema) ? JSON.stringify(schema) : undefined;
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap the native Fastify response serializer builder supplied by the application.
|
|
36
|
+
* Each invocation owns its external schemas, serializer options and cache. Only
|
|
37
|
+
* plain JSON schemas share compilation; custom schema inputs delegate unchanged.
|
|
38
|
+
* Custom compilers that depend on route metadata should remain unwrapped.
|
|
39
|
+
*/
|
|
40
|
+
function createCachedResponseSerializerFactory(buildSerializer) {
|
|
41
|
+
if (typeof buildSerializer !== "function") {
|
|
42
|
+
throw new TypeError("createCachedResponseSerializerFactory requires a serializer builder.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return function buildCachedSerializer(externalSchemas, serializerOptions) {
|
|
46
|
+
const compile = buildSerializer(externalSchemas, serializerOptions);
|
|
47
|
+
const serializers = new Map();
|
|
48
|
+
|
|
49
|
+
return function compileResponse(options) {
|
|
50
|
+
const key = schemaCacheKey(options.schema);
|
|
51
|
+
if (key === undefined) return compile(options);
|
|
52
|
+
if (!serializers.has(key)) serializers.set(key, compile(options));
|
|
53
|
+
return serializers.get(key);
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { createCachedResponseSerializerFactory };
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import Fastify from "fastify";
|
|
5
|
+
import SerializerSelector from "@fastify/fast-json-stringify-compiler";
|
|
6
|
+
import { createCachedResponseSerializerFactory } from "./responseSerializerFactory.js";
|
|
7
|
+
|
|
8
|
+
const linkSchema = {
|
|
9
|
+
anyOf: [{ type: "string", minLength: 1 }, { type: "object", additionalProperties: true }]
|
|
10
|
+
};
|
|
11
|
+
const linksSchema = { type: "object", additionalProperties: linkSchema };
|
|
12
|
+
const errorSchema = {
|
|
13
|
+
type: "object",
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
required: ["errors"],
|
|
16
|
+
properties: {
|
|
17
|
+
errors: {
|
|
18
|
+
type: "array",
|
|
19
|
+
minItems: 1,
|
|
20
|
+
items: {
|
|
21
|
+
type: "object",
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
properties: {
|
|
24
|
+
status: { type: "string" },
|
|
25
|
+
detail: { type: "string" },
|
|
26
|
+
links: linksSchema,
|
|
27
|
+
meta: { type: "object", additionalProperties: true }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
links: linksSchema
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const successSchema = {
|
|
35
|
+
type: "object",
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
required: ["data"],
|
|
38
|
+
properties: {
|
|
39
|
+
data: {
|
|
40
|
+
anyOf: [
|
|
41
|
+
{
|
|
42
|
+
type: "object",
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
required: ["type", "id"],
|
|
45
|
+
properties: {
|
|
46
|
+
type: { const: "records" },
|
|
47
|
+
id: { anyOf: [{ type: "string", minLength: 1 }, { type: "number" }] },
|
|
48
|
+
attributes: {
|
|
49
|
+
type: "object",
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
properties: { title: { type: "string" } }
|
|
52
|
+
},
|
|
53
|
+
links: linksSchema
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{ type: "null" }
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
links: linksSchema
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function trackNativeFactory() {
|
|
64
|
+
const build = SerializerSelector();
|
|
65
|
+
const contexts = [];
|
|
66
|
+
return {
|
|
67
|
+
contexts,
|
|
68
|
+
build(externalSchemas, options) {
|
|
69
|
+
const compile = build(externalSchemas, options);
|
|
70
|
+
const calls = [];
|
|
71
|
+
contexts.push({ externalSchemas, options, calls });
|
|
72
|
+
return (input) => {
|
|
73
|
+
calls.push(input);
|
|
74
|
+
return compile(input);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("reuses exact JSON schemas and preserves native rejection without caching failures", () => {
|
|
81
|
+
const native = trackNativeFactory();
|
|
82
|
+
const compile = createCachedResponseSerializerFactory(native.build)({}, {});
|
|
83
|
+
const first = compile({ schema: successSchema, url: "/one", httpStatus: "200" });
|
|
84
|
+
const second = compile({ schema: structuredClone(successSchema), url: "/two", httpStatus: "201" });
|
|
85
|
+
assert.equal(second, first);
|
|
86
|
+
assert.equal(native.contexts[0].calls.length, 1);
|
|
87
|
+
const errors = compile({ schema: errorSchema });
|
|
88
|
+
assert.notEqual(errors, first);
|
|
89
|
+
assert.equal(native.contexts[0].calls.length, 2);
|
|
90
|
+
|
|
91
|
+
const schema = { type: "string" };
|
|
92
|
+
const string = compile({ schema });
|
|
93
|
+
schema.type = "integer";
|
|
94
|
+
const integer = compile({ schema });
|
|
95
|
+
assert.notEqual(integer, string);
|
|
96
|
+
assert.equal(string(3), '"3"');
|
|
97
|
+
assert.equal(integer(3), "3");
|
|
98
|
+
|
|
99
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
100
|
+
assert.throws(() => compile({ schema: { $ref: "missing" } }), /reference|schema/i);
|
|
101
|
+
}
|
|
102
|
+
assert.equal(native.contexts[0].calls.length, 6);
|
|
103
|
+
assert.throws(() => createCachedResponseSerializerFactory(null), /serializer builder/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("non-JSON schema inputs delegate unchanged without lossy cache keys or getter calls", () => {
|
|
107
|
+
const calls = [];
|
|
108
|
+
const compile = createCachedResponseSerializerFactory(() => (options) => {
|
|
109
|
+
calls.push(options);
|
|
110
|
+
return () => "custom";
|
|
111
|
+
})({}, {});
|
|
112
|
+
let getterCalls = 0;
|
|
113
|
+
const accessor = Object.defineProperty({}, "type", {
|
|
114
|
+
enumerable: true,
|
|
115
|
+
get() { getterCalls++; return "string"; }
|
|
116
|
+
});
|
|
117
|
+
const hidden = Object.defineProperty({}, "type", { value: "integer" });
|
|
118
|
+
const cycle = {};
|
|
119
|
+
cycle.self = cycle;
|
|
120
|
+
const namedArray = [1];
|
|
121
|
+
namedArray.extra = true;
|
|
122
|
+
const schemas = [
|
|
123
|
+
{ default: undefined }, { default: NaN }, { default: Infinity }, { default: 1n },
|
|
124
|
+
{ default: new Date(0) }, { pattern: /example/ }, { keyword() {} },
|
|
125
|
+
{ [Symbol("custom")]: true }, accessor, hidden, cycle,
|
|
126
|
+
{ default: new Array(2) }, { default: namedArray },
|
|
127
|
+
Object.create({ type: "string" }),
|
|
128
|
+
{ toJSON() { throw new Error("must not execute toJSON"); } }
|
|
129
|
+
];
|
|
130
|
+
for (const schema of schemas) {
|
|
131
|
+
const options = { schema, method: "GET", url: "/custom" };
|
|
132
|
+
assert.notEqual(compile(options), compile(options));
|
|
133
|
+
assert.equal(calls.at(-1), options);
|
|
134
|
+
}
|
|
135
|
+
assert.equal(calls.length, schemas.length * 2);
|
|
136
|
+
assert.equal(getterCalls, 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("each factory invocation isolates serializer options and external references", () => {
|
|
140
|
+
const native = trackNativeFactory();
|
|
141
|
+
const build = createCachedResponseSerializerFactory(native.build);
|
|
142
|
+
const schema = { $ref: "value" };
|
|
143
|
+
const integers = { value: { $id: "value", type: "integer" } };
|
|
144
|
+
const strings = { value: { $id: "value", type: "string" } };
|
|
145
|
+
const floor = build(integers, { rounding: "floor" })({ schema });
|
|
146
|
+
const ceil = build(integers, { rounding: "ceil" })({ schema });
|
|
147
|
+
const string = build(strings, {})({ schema });
|
|
148
|
+
const another = build(strings, {})({ schema });
|
|
149
|
+
assert.equal(floor(3.8), "3");
|
|
150
|
+
assert.equal(ceil(3.8), "4");
|
|
151
|
+
assert.equal(string(3.8), '"3.8"');
|
|
152
|
+
assert.notEqual(another, string);
|
|
153
|
+
assert.equal(native.contexts.length, 4);
|
|
154
|
+
assert.ok(native.contexts.every(({ calls }) => calls.length === 1));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("Fastify startup reuses serializers with identical native HTTP output and errors", async (t) => {
|
|
158
|
+
const native = trackNativeFactory();
|
|
159
|
+
const cached = trackNativeFactory();
|
|
160
|
+
const applications = [native.build, createCachedResponseSerializerFactory(cached.build)]
|
|
161
|
+
.map((buildSerializer) => Fastify({ schemaController: { compilersFactory: { buildSerializer } } }));
|
|
162
|
+
let payload;
|
|
163
|
+
let status;
|
|
164
|
+
for (const app of applications) {
|
|
165
|
+
t.after(() => app.close());
|
|
166
|
+
for (const url of ["/one", "/two"]) {
|
|
167
|
+
app.get(url, {
|
|
168
|
+
schema: {
|
|
169
|
+
response: {
|
|
170
|
+
200: structuredClone(successSchema),
|
|
171
|
+
201: { content: { "application/vnd.api+json": { schema: structuredClone(successSchema) } } },
|
|
172
|
+
400: structuredClone(errorSchema),
|
|
173
|
+
403: structuredClone(errorSchema)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}, async (_request, reply) => reply.code(status).type("application/vnd.api+json").send(payload));
|
|
177
|
+
}
|
|
178
|
+
await app.ready();
|
|
179
|
+
}
|
|
180
|
+
assert.equal(cached.contexts.length, 1);
|
|
181
|
+
assert.equal(cached.contexts[0].calls.length, 2);
|
|
182
|
+
assert.ok(native.contexts[0].calls.length > 2);
|
|
183
|
+
|
|
184
|
+
const cases = [
|
|
185
|
+
[200, { data: { type: "records", id: 1, attributes: { title: "One", ignored: true } }, ignored: true }],
|
|
186
|
+
[201, { data: { type: "records", id: "two", links: { self: "/two", related: { href: "/three" } } } }],
|
|
187
|
+
[200, { data: null }],
|
|
188
|
+
[200, {}],
|
|
189
|
+
[200, { data: { type: "records" } }],
|
|
190
|
+
[200, { data: { type: "records", id: "" } }],
|
|
191
|
+
[400, { errors: [{ status: "400", detail: "Invalid", ignored: true, meta: { field: "title" } }] }],
|
|
192
|
+
[403, { errors: [{ status: "403", links: { help: "/help" } }] }],
|
|
193
|
+
...["", null, ["/invalid"]].flatMap((link) => [
|
|
194
|
+
[200, { data: { type: "records", id: "one" }, links: { self: link } }],
|
|
195
|
+
[400, { errors: [{ status: "400", links: { self: link } }] }]
|
|
196
|
+
])
|
|
197
|
+
];
|
|
198
|
+
for ([status, payload] of cases) {
|
|
199
|
+
const original = await applications[0].inject("/one");
|
|
200
|
+
const reused = await applications[1].inject("/two");
|
|
201
|
+
assert.equal(reused.statusCode, original.statusCode);
|
|
202
|
+
assert.equal(reused.headers["content-type"], original.headers["content-type"]);
|
|
203
|
+
assert.equal(reused.body, original.body);
|
|
204
|
+
}
|
|
205
|
+
for (const link of ["", null, ["/invalid"]]) {
|
|
206
|
+
status = 400;
|
|
207
|
+
payload = { errors: [{ links: { self: link } }] };
|
|
208
|
+
assert.equal((await applications[1].inject("/two")).statusCode, 500);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("Fastify encapsulation and schema additions create isolated compiler contexts", async (t) => {
|
|
213
|
+
const native = trackNativeFactory();
|
|
214
|
+
const app = Fastify({
|
|
215
|
+
schemaController: { compilersFactory: { buildSerializer: createCachedResponseSerializerFactory(native.build) } }
|
|
216
|
+
});
|
|
217
|
+
t.after(() => app.close());
|
|
218
|
+
app.register(async (strings) => {
|
|
219
|
+
strings.addSchema({ $id: "value", type: "string" });
|
|
220
|
+
strings.get("/string", { schema: { response: { 200: { $ref: "value" } } } }, async () => 12);
|
|
221
|
+
strings.register(async (nested) => {
|
|
222
|
+
nested.addSchema({ $id: "extra", type: "boolean" });
|
|
223
|
+
nested.get("/nested", { schema: { response: { 200: { $ref: "value" } } } }, async () => 13);
|
|
224
|
+
nested.get("/added", { schema: { response: { 200: { $ref: "extra" } } } }, async () => true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
app.register(async (integers) => {
|
|
228
|
+
integers.addSchema({ $id: "value", type: "integer" });
|
|
229
|
+
integers.get("/integer", { schema: { response: { 200: { $ref: "value" } } } }, async () => 12);
|
|
230
|
+
});
|
|
231
|
+
await app.ready();
|
|
232
|
+
assert.equal((await app.inject("/string")).body, '"12"');
|
|
233
|
+
assert.equal((await app.inject("/nested")).body, '"13"');
|
|
234
|
+
assert.equal((await app.inject("/added")).body, "true");
|
|
235
|
+
assert.equal((await app.inject("/integer")).body, "12");
|
|
236
|
+
assert.deepEqual(new Set(native.contexts.map(({ externalSchemas }) =>
|
|
237
|
+
`${externalSchemas.value.type}:${Object.keys(externalSchemas).sort().join(",")}`
|
|
238
|
+
)), new Set(["string:value", "string:extra,value", "integer:value"]));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("Fastify application and route serializer overrides remain authoritative", async (t) => {
|
|
242
|
+
const native = trackNativeFactory();
|
|
243
|
+
const options = {
|
|
244
|
+
schemaController: { compilersFactory: { buildSerializer: createCachedResponseSerializerFactory(native.build) } }
|
|
245
|
+
};
|
|
246
|
+
const app = Fastify(options);
|
|
247
|
+
t.after(() => app.close());
|
|
248
|
+
const inputs = [];
|
|
249
|
+
app.setSerializerCompiler((input) => {
|
|
250
|
+
inputs.push(input);
|
|
251
|
+
return () => JSON.stringify({ owner: "application", url: input.url });
|
|
252
|
+
});
|
|
253
|
+
app.get("/app", { schema: { response: { 200: { type: "object" } } } }, async () => ({}));
|
|
254
|
+
app.get("/route", {
|
|
255
|
+
schema: { response: { 200: { type: "object" } } },
|
|
256
|
+
serializerCompiler: (input) => () => JSON.stringify({ owner: "route", url: input.url })
|
|
257
|
+
}, async () => ({}));
|
|
258
|
+
await app.ready();
|
|
259
|
+
assert.deepEqual((await app.inject("/app")).json(), { owner: "application", url: "/app" });
|
|
260
|
+
assert.deepEqual((await app.inject("/route")).json(), { owner: "route", url: "/route" });
|
|
261
|
+
assert.equal(native.contexts.length, 0);
|
|
262
|
+
assert.ok(inputs.every(({ url, schema }) => url === "/app" && schema.type === "object"));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("native duplicate schema-id failures are unchanged", (t) => {
|
|
266
|
+
for (const buildSerializer of [SerializerSelector(), createCachedResponseSerializerFactory(SerializerSelector())]) {
|
|
267
|
+
const app = Fastify({ schemaController: { compilersFactory: { buildSerializer } } });
|
|
268
|
+
t.after(() => app.close());
|
|
269
|
+
app.addSchema({ $id: "duplicate", type: "string" });
|
|
270
|
+
assert.throws(() => app.addSchema({ $id: "duplicate", type: "integer" }), { code: "FST_ERR_SCH_ALREADY_PRESENT" });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("generic and HTTP kernel imports do not load Fastify or response-compiler dependencies", () => {
|
|
275
|
+
const result = spawnSync(process.execPath, ["--input-type=module", "-e", `
|
|
276
|
+
import { registerHooks } from "node:module";
|
|
277
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
278
|
+
if (specifier.startsWith("@fastify/") || ["fastify", "fast-json-stringify", "ajv"].includes(specifier.split("/")[0])) {
|
|
279
|
+
throw new Error("Unexpected framework import: " + specifier);
|
|
280
|
+
}
|
|
281
|
+
return nextResolve(specifier, context);
|
|
282
|
+
} });
|
|
283
|
+
await import(${JSON.stringify(new URL("../platform/index.js", import.meta.url).href)});
|
|
284
|
+
await import(${JSON.stringify(new URL("./index.js", import.meta.url).href)});
|
|
285
|
+
`], { encoding: "utf8" });
|
|
286
|
+
assert.equal(result.status, 0, result.stderr);
|
|
287
|
+
});
|
|
@@ -131,6 +131,7 @@ const BARREL_EXPECTATIONS = Object.freeze([
|
|
|
131
131
|
filePath: path.join(REPO_ROOT, "packages", "kernel", "server", "http", "index.js"),
|
|
132
132
|
expectedExports: Object.freeze([
|
|
133
133
|
"HttpProvider",
|
|
134
|
+
"createCachedResponseSerializerFactory",
|
|
134
135
|
"createCapabilityHttpRuntime"
|
|
135
136
|
])
|
|
136
137
|
})
|