@jskit-ai/kernel 0.1.57 → 0.1.58

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "json-rest-schema": "1.x.x"
@@ -760,6 +760,51 @@ test("registerRoutes attaches request.input when route input transforms are conf
760
760
  assert.equal(reply.statusCode, 200);
761
761
  });
762
762
 
763
+ test("registerRoutes strips Fastify body schemas when JSKIT runtime body validation is present", () => {
764
+ const fastify = createFastifyStub();
765
+
766
+ registerRoutes(fastify, {
767
+ routes: [
768
+ {
769
+ method: "POST",
770
+ path: "/body-schema-runtime-validation",
771
+ schema: {
772
+ body: {
773
+ type: "object",
774
+ properties: {
775
+ flag: {
776
+ anyOf: [{ type: "boolean" }, { type: "null" }]
777
+ }
778
+ }
779
+ },
780
+ querystring: {
781
+ type: "object",
782
+ properties: {
783
+ limit: { type: "integer" }
784
+ }
785
+ }
786
+ },
787
+ input: {
788
+ body: (body) => ({
789
+ flag: body?.flag ?? null
790
+ })
791
+ },
792
+ handler: async (_request, reply) => {
793
+ reply.code(200).send({ ok: true });
794
+ }
795
+ }
796
+ ]
797
+ });
798
+
799
+ assert.equal(Object.prototype.hasOwnProperty.call(fastify.routes[0].schema, "body"), false);
800
+ assert.deepEqual(fastify.routes[0].schema.querystring, {
801
+ type: "object",
802
+ properties: {
803
+ limit: { type: "integer" }
804
+ }
805
+ });
806
+ });
807
+
763
808
  test("registerRoutes applies transport request transforms before route input normalization", async () => {
764
809
  const fastify = createFastifyStub();
765
810
 
@@ -979,6 +1024,116 @@ test("registerRoutes applies transport response transforms before reply serializ
979
1024
  });
980
1025
  });
981
1026
 
1027
+ test("registerRoutes bypasses success transport and output transforms for error replies", async () => {
1028
+ const fastify = createFastifyStub();
1029
+ let successTransportCalls = 0;
1030
+ let outputTransformCalls = 0;
1031
+
1032
+ registerRoutes(fastify, {
1033
+ routes: [
1034
+ {
1035
+ method: "GET",
1036
+ path: "/transport-error-bypass",
1037
+ transport: {
1038
+ kind: "jsonapi-resource",
1039
+ contentType: "application/vnd.api+json",
1040
+ response() {
1041
+ successTransportCalls += 1;
1042
+ return {
1043
+ data: {
1044
+ type: "contacts",
1045
+ id: "7",
1046
+ attributes: {
1047
+ name: "Alice"
1048
+ }
1049
+ }
1050
+ };
1051
+ }
1052
+ },
1053
+ output() {
1054
+ outputTransformCalls += 1;
1055
+ return {
1056
+ mutated: true
1057
+ };
1058
+ },
1059
+ handler: async (_request, reply) => {
1060
+ reply.code(500).send({
1061
+ errors: [
1062
+ {
1063
+ status: "500",
1064
+ code: "internal_server_error",
1065
+ title: "Internal server error."
1066
+ }
1067
+ ]
1068
+ });
1069
+ }
1070
+ }
1071
+ ]
1072
+ });
1073
+
1074
+ const request = {};
1075
+ const reply = createReplyStub();
1076
+
1077
+ await fastify.routes[0].handler(request, reply);
1078
+
1079
+ assert.equal(reply.statusCode, 500);
1080
+ assert.equal(reply.headers["Content-Type"], "application/vnd.api+json");
1081
+ assert.equal(successTransportCalls, 0);
1082
+ assert.equal(outputTransformCalls, 0);
1083
+ assert.deepEqual(reply.payload, {
1084
+ errors: [
1085
+ {
1086
+ status: "500",
1087
+ code: "internal_server_error",
1088
+ title: "Internal server error."
1089
+ }
1090
+ ]
1091
+ });
1092
+ });
1093
+
1094
+ test("registerRoutes bypasses success transport for Error payloads before status normalization", async () => {
1095
+ const fastify = createFastifyStub();
1096
+ let successTransportCalls = 0;
1097
+
1098
+ registerRoutes(fastify, {
1099
+ routes: [
1100
+ {
1101
+ method: "GET",
1102
+ path: "/transport-error-instance",
1103
+ transport: {
1104
+ kind: "jsonapi-resource",
1105
+ contentType: "application/vnd.api+json",
1106
+ response() {
1107
+ successTransportCalls += 1;
1108
+ return {
1109
+ data: {
1110
+ type: "contacts",
1111
+ id: "7",
1112
+ attributes: {
1113
+ name: "Alice"
1114
+ }
1115
+ }
1116
+ };
1117
+ }
1118
+ },
1119
+ handler: async (_request, reply) => {
1120
+ reply.send(new Error("Boom"));
1121
+ }
1122
+ }
1123
+ ]
1124
+ });
1125
+
1126
+ const request = {};
1127
+ const reply = createReplyStub();
1128
+
1129
+ await fastify.routes[0].handler(request, reply);
1130
+
1131
+ assert.equal(successTransportCalls, 0);
1132
+ assert.equal(reply.headers["Content-Type"], "application/vnd.api+json");
1133
+ assert.equal(reply.payload instanceof Error, true);
1134
+ assert.equal(reply.payload.message, "Boom");
1135
+ });
1136
+
982
1137
  test("registerRoutes exposes full transport runtime on Fastify route config for pre-handler error serialization", () => {
983
1138
  const fastify = createFastifyStub();
984
1139
  const transport = {
@@ -17,6 +17,9 @@ const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
17
17
  function toFastifyRouteOptions(route) {
18
18
  const sourceRoute = normalizeObject(route);
19
19
  const schema = cloneRouteSchema(sourceRoute.schema);
20
+ if (shouldStripFastifyBodySchema(sourceRoute, schema)) {
21
+ delete schema.body;
22
+ }
20
23
  const existingConfig = normalizeObject(sourceRoute.config);
21
24
  const transportKind = normalizeText(sourceRoute?.transport?.kind).toLowerCase();
22
25
  const existingTransportConfig =
@@ -48,6 +51,18 @@ function toFastifyRouteOptions(route) {
48
51
  };
49
52
  }
50
53
 
54
+ function shouldStripFastifyBodySchema(route = null, schema = null) {
55
+ if (!route || !schema || typeof schema !== "object" || Array.isArray(schema)) {
56
+ return false;
57
+ }
58
+
59
+ if (!Object.prototype.hasOwnProperty.call(schema, "body")) {
60
+ return false;
61
+ }
62
+
63
+ return typeof route?.input?.body === "function";
64
+ }
65
+
51
66
  function normalizeHeaderValue(value) {
52
67
  if (Array.isArray(value)) {
53
68
  return String(value[0] || "").trim();
@@ -210,6 +225,19 @@ function wrapReplySend({ reply = null, request = null, route = null, outputTrans
210
225
 
211
226
  const originalSend = reply.send.bind(reply);
212
227
  reply.send = function transformedSend(payload) {
228
+ const statusCode = Number(reply?.statusCode || 200);
229
+
230
+ if (payload instanceof Error || statusCode >= 400) {
231
+ if (
232
+ normalizeText(transport?.contentType) &&
233
+ !replyHasHeader(reply, "content-type")
234
+ ) {
235
+ reply.header("Content-Type", transport.contentType);
236
+ }
237
+
238
+ return originalSend(payload);
239
+ }
240
+
213
241
  let nextPayload = payload;
214
242
  if (typeof transport?.response === "function") {
215
243
  const transportedPayload = transport.response(payload, {
@@ -217,7 +245,7 @@ function wrapReplySend({ reply = null, request = null, route = null, outputTrans
217
245
  reply,
218
246
  route,
219
247
  transport,
220
- statusCode: Number(reply?.statusCode || 200)
248
+ statusCode
221
249
  });
222
250
 
223
251
  if (transportedPayload && typeof transportedPayload.then === "function") {
@@ -235,7 +263,7 @@ function wrapReplySend({ reply = null, request = null, route = null, outputTrans
235
263
  reply,
236
264
  route,
237
265
  transport,
238
- statusCode: Number(reply?.statusCode || 200)
266
+ statusCode
239
267
  });
240
268
 
241
269
  if (transformedPayload && typeof transformedPayload.then === "function") {
@@ -249,7 +277,7 @@ function wrapReplySend({ reply = null, request = null, route = null, outputTrans
249
277
 
250
278
  if (
251
279
  normalizeText(transport?.contentType) &&
252
- Number(reply?.statusCode || 200) !== 204 &&
280
+ statusCode !== 204 &&
253
281
  !replyHasHeader(reply, "content-type")
254
282
  ) {
255
283
  reply.header("Content-Type", transport.contentType);
@@ -39,6 +39,18 @@ async function createProviderRuntimeApp({
39
39
 
40
40
  await app.start({ providers });
41
41
 
42
+ if (fastify && typeof fastify.addHook === "function") {
43
+ let didShutdown = false;
44
+ fastify.addHook("onClose", async () => {
45
+ if (didShutdown) {
46
+ return;
47
+ }
48
+
49
+ didShutdown = true;
50
+ await app.shutdown();
51
+ });
52
+ }
53
+
42
54
  const routeRegistration = httpRuntime ? httpRuntime.registerRoutes() : { routeCount: 0 };
43
55
  return Object.freeze({
44
56
  app,
@@ -215,3 +215,115 @@ test("createProviderRuntimeFromApp resolves descriptor using source.packagePath"
215
215
  await rm(appRoot, { recursive: true, force: true });
216
216
  }
217
217
  });
218
+
219
+ test("createProviderRuntimeFromApp wires fastify onClose to provider shutdown exactly once", async () => {
220
+ const appRoot = await createTestAppRoot("kernel-provider-runtime-fastify-close-");
221
+ try {
222
+ await mkdir(path.join(appRoot, "packages", "local-example", "src", "server", "providers"), { recursive: true });
223
+ await writeFile(
224
+ path.join(appRoot, ".jskit", "lock.json"),
225
+ `${JSON.stringify(
226
+ {
227
+ lockVersion: 1,
228
+ installedPackages: {
229
+ "@local/example": {
230
+ packageId: "@local/example",
231
+ version: "0.1.0",
232
+ source: {
233
+ type: "local-package",
234
+ packagePath: "packages/local-example"
235
+ },
236
+ managed: {
237
+ packageJson: {
238
+ dependencies: {},
239
+ devDependencies: {},
240
+ scripts: {}
241
+ },
242
+ text: {},
243
+ files: []
244
+ },
245
+ options: {},
246
+ installedAt: "2026-01-01T00:00:00.000Z"
247
+ }
248
+ }
249
+ },
250
+ null,
251
+ 2
252
+ )}\n`,
253
+ "utf8"
254
+ );
255
+ await writeFile(
256
+ path.join(appRoot, "packages", "local-example", "package.descriptor.mjs"),
257
+ [
258
+ "export default Object.freeze({",
259
+ " packageVersion: 1,",
260
+ " packageId: \"@local/example\",",
261
+ " version: \"0.1.0\",",
262
+ " description: \"Local example package\",",
263
+ " dependsOn: [],",
264
+ " capabilities: {",
265
+ " provides: [],",
266
+ " requires: []",
267
+ " },",
268
+ " runtime: {",
269
+ " server: {",
270
+ " providers: [",
271
+ " { discover: { dir: \"src/server/providers\", pattern: \"*Provider.js\" } }",
272
+ " ]",
273
+ " }",
274
+ " }",
275
+ "});"
276
+ ].join("\n"),
277
+ "utf8"
278
+ );
279
+ await writeFile(
280
+ path.join(appRoot, "packages", "local-example", "src", "server", "providers", "CloseAwareProvider.js"),
281
+ [
282
+ "export default class CloseAwareProvider {",
283
+ " static id = \"example.close-aware\";",
284
+ " register(app) {",
285
+ " app.instance(\"example.close.state\", { shutdownCalls: 0 });",
286
+ " }",
287
+ " async boot() {}",
288
+ " async shutdown(app) {",
289
+ " app.make(\"example.close.state\").shutdownCalls += 1;",
290
+ " }",
291
+ "}"
292
+ ].join("\n"),
293
+ "utf8"
294
+ );
295
+
296
+ const hooks = [];
297
+ const fastify = {
298
+ route() {},
299
+ setErrorHandler() {},
300
+ addContentTypeParser() {},
301
+ hasContentTypeParser() {
302
+ return false;
303
+ },
304
+ getDefaultJsonParser() {
305
+ return (_request, body, done) => done(null, body);
306
+ },
307
+ addHook(name, handler) {
308
+ hooks.push({ name, handler });
309
+ }
310
+ };
311
+
312
+ const runtime = await createProviderRuntimeFromApp({
313
+ appRoot,
314
+ profile: "app",
315
+ fastify
316
+ });
317
+
318
+ const closeHook = hooks.find((entry) => entry.name === "onClose");
319
+ assert.ok(closeHook);
320
+ assert.equal(runtime.app.make("example.close.state").shutdownCalls, 0);
321
+
322
+ await closeHook.handler();
323
+ await closeHook.handler();
324
+
325
+ assert.equal(runtime.app.make("example.close.state").shutdownCalls, 1);
326
+ } finally {
327
+ await rm(appRoot, { recursive: true, force: true });
328
+ }
329
+ });
@@ -19,9 +19,11 @@ import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
19
19
  import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
20
20
 
21
21
  const DEFAULT_PAGE_LINK_COMPONENT_TOKEN = "local.main.ui.surface-aware-menu-link-item";
22
- const DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
22
+ const DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN = "local.main.ui.surface-aware-menu-link-item";
23
23
  const PAGE_ROOT_PREFIX = "src/pages/";
24
24
  const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
25
+ const SECTION_CONTAINER_SHELL_TAG_PATTERN = /<SectionContainerShell\b/i;
26
+ const TABS_SLOT_PATTERN = /<template\b[^>]*(?:#tabs|v-slot:tabs)\b[^>]*>([\s\S]*?)<\/template>/i;
25
27
 
26
28
  function normalizeRelativeFilePath(value = "") {
27
29
  return String(value || "")
@@ -430,7 +432,6 @@ function resolveSubpagesHostTargetFromPageSource(source = "") {
430
432
  if (!ROUTER_VIEW_TAG_PATTERN.test(sourceText)) {
431
433
  return null;
432
434
  }
433
-
434
435
  const discoveredTargets = discoverShellOutletTargetsFromVueSource(sourceText, {
435
436
  context: "subpages host"
436
437
  });
@@ -444,8 +445,25 @@ function resolveSubpagesHostTargetFromPageSource(source = "") {
444
445
  return null;
445
446
  }
446
447
 
448
+ let isSectionSubpagesHost = false;
449
+ if (SECTION_CONTAINER_SHELL_TAG_PATTERN.test(sourceText)) {
450
+ const tabsSlotMatch = TABS_SLOT_PATTERN.exec(sourceText);
451
+ if (tabsSlotMatch) {
452
+ const tabsTargets = discoverShellOutletTargetsFromVueSource(String(tabsSlotMatch[1] || ""), {
453
+ context: "subpages host tabs slot"
454
+ });
455
+ const tabTargetIds = Array.isArray(tabsTargets.targets)
456
+ ? tabsTargets.targets.map((entry) => normalizeShellOutletTargetId(entry?.id)).filter(Boolean)
457
+ : [];
458
+ if (tabTargetIds.length === 1 && tabTargetIds[0] === normalizeShellOutletTargetId(target.id)) {
459
+ isSectionSubpagesHost = true;
460
+ }
461
+ }
462
+ }
463
+
447
464
  return Object.freeze({
448
- id: target.id
465
+ id: target.id,
466
+ isSectionSubpagesHost
449
467
  });
450
468
  }
451
469
 
@@ -594,16 +612,11 @@ function resolveInferredPageLinkTo({
594
612
  const parentTargetId = normalizePlacementTargetId(parentHost);
595
613
  const placementTargetId = normalizePlacementTargetId(placementTarget);
596
614
  if (parentTargetId && parentTargetId === placementTargetId) {
597
- const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
598
- if (inferredLinkTo) {
599
- return inferredLinkTo;
600
- }
601
- }
602
-
603
- if (normalizeText(parentHost?.pageShape) === "index") {
604
- const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
605
- if (inferredLinkTo) {
606
- return inferredLinkTo;
615
+ if (parentHost?.isSectionSubpagesHost === true) {
616
+ const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
617
+ if (inferredLinkTo) {
618
+ return inferredLinkTo;
619
+ }
607
620
  }
608
621
  }
609
622
 
@@ -633,7 +646,11 @@ function resolveInferredPageLinkComponentToken({
633
646
 
634
647
  const parentTargetId = normalizePlacementTargetId(parentHost);
635
648
  const placementTargetId = normalizePlacementTargetId(placementTarget);
636
- if (parentTargetId && parentTargetId === placementTargetId) {
649
+ if (
650
+ parentHost?.isSectionSubpagesHost === true &&
651
+ parentTargetId &&
652
+ parentTargetId === placementTargetId
653
+ ) {
637
654
  return normalizeText(subpageComponentToken) || DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN;
638
655
  }
639
656
 
@@ -681,6 +698,12 @@ async function resolvePageLinkTargetDetails({
681
698
  defaultComponentToken,
682
699
  subpageComponentToken
683
700
  });
701
+ const parentTargetId = normalizePlacementTargetId(parentHost);
702
+ const placementTargetId = normalizePlacementTargetId(placementTarget);
703
+ const preservesRelativeSubpageLinks =
704
+ parentHost?.isSectionSubpagesHost === true &&
705
+ Boolean(parentTargetId) &&
706
+ parentTargetId === placementTargetId;
684
707
 
685
708
  return Object.freeze({
686
709
  pageTarget: resolvedPageTarget,
@@ -693,7 +716,9 @@ async function resolvePageLinkTargetDetails({
693
716
  pageTarget: resolvedPageTarget,
694
717
  parentHost,
695
718
  placementTarget,
696
- suppressImplicitRelativeLinks: resolvedComponentToken === (normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN)
719
+ suppressImplicitRelativeLinks:
720
+ resolvedComponentToken === (normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN) &&
721
+ preservesRelativeSubpageLinks !== true
697
722
  })
698
723
  });
699
724
  }
@@ -354,7 +354,7 @@ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host",
354
354
 
355
355
  assert.equal(details.parentHost?.id, "contact-view:sub-pages");
356
356
  assert.equal(details.placementTarget.id, "contact-view:sub-pages");
357
- assert.equal(details.componentToken, "local.main.ui.tab-link-item");
357
+ assert.equal(details.componentToken, "local.main.ui.surface-aware-menu-link-item");
358
358
  assert.equal(details.linkTo, "./notes");
359
359
  });
360
360
  });
@@ -422,7 +422,7 @@ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host
422
422
  assert.equal(details.parentHost?.id, "customer-view:sub-pages");
423
423
  assert.equal(details.parentHost?.pageFile, "src/pages/admin/customers/[customerId]/index.vue");
424
424
  assert.equal(details.placementTarget.id, "customer-view:sub-pages");
425
- assert.equal(details.componentToken, "local.main.ui.tab-link-item");
425
+ assert.equal(details.componentToken, "local.main.ui.surface-aware-menu-link-item");
426
426
  assert.equal(details.linkTo, "./pets");
427
427
  });
428
428
  });