diff --git a/.changeset/http-server-exemption-revoked.md b/.changeset/http-server-exemption-revoked.md new file mode 100644 index 0000000000..0950854c02 --- /dev/null +++ b/.changeset/http-server-exemption-revoked.md @@ -0,0 +1,41 @@ +--- +"@objectstack/metadata": patch +--- + +fix(lint,metadata): revoke the `http.server` lint exemption — its stated reason was false (#4251) + +`http.server` was added to `UNCONTRACTED_SLOTS` in #4321 on the ground that +"no IHttpServer contract exists". The contract does exist — +`packages/spec/src/contracts/http-server.ts` — and eight call sites were +already resolving the slot as `getService(…)` when the exemption +was written. An exemption is a claim like any other, and this one rested on a +premise nobody checked: the same shape as the gaps the rule exists to find. + +Revoked. That surfaced **9 erasures the exemption had been hiding** — 7 in +files never grandfathered, 2 as count growth inside grandfathered ones, none of +which the baseline could legally absorb. All typed to `IHttpServer`; +`packages/metadata/src/plugin.ts` came out clean entirely, so the baseline +ratchets **DOWN to 168 sites in 36 files** and loses a file. + +Two things confirmed on the way, reported rather than changed: + +**`http.server` and `http-server` are the same instance under two names.** +plugin-hono-server and qa's node-plugin each register it twice, two lines +apart; runtime's `config.server` path registers only `http.server`. +`metadata/src/plugin.ts` reads both with a `??`, which is how it survived. No +registration is removed here — that is a runtime-behaviour change and belongs +with whoever picks the canonical name. + +**`IHttpServer` is defined twice and the two have already diverged.** +`packages/spec/src/contracts/http-server.ts` (15 importers) declares `write?()` +and `end?()`; `packages/core/src/contracts/http-server.ts` (8 importers) does +not. Spec's is the superset and the one the ledger points at, so it is the +source; core's is a stale near-copy and should re-export it. Left for its own +change — collapsing a duplicated contract is not a lint fix. + +Also worth a note for whoever writes the wider HTTP contract: `getRawApp()` now +has a **third** independent consumer (metadata's HMR routes, joining +cloud-connection's two). It is deliberately absent from `IHttpServer` — the +contract is framework-agnostic and the raw app is the framework's own handle — +so each consumer names it locally. Three is enough evidence to decide whether +that stays the right answer. diff --git a/eslint.config.mjs b/eslint.config.mjs index e960e8a512..d0206dba67 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -69,11 +69,16 @@ const SLOT_LOOKUPS = ['resolveService', 'getService', 'getRequestKernelService'] // place, not sprinkled through the code. Deleting a name from this list is how // the exemption ends once that contract gets written. // -// Entries are spliced into a regex, so escape metacharacters (`http\\.server`). -// `http.server` is served by three providers (plugin-hono-server, runtime's -// config.server path, qa's node-plugin) and no IHttpServer contract exists; -// callers read only `getPort()`. -const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager', 'http\\.server'].join('|'); +// Entries are spliced into a regex, so escape metacharacters if one returns. +// +// [#4251] `http.server` was here, on the stated ground that "no IHttpServer +// contract exists". That was FALSE when it was written: the contract is +// `packages/spec/src/contracts/http-server.ts`, and eight call sites were +// already resolving the slot as `getService(…)`. An exemption is a +// claim like any other, and this one rested on a premise nobody checked — the +// same shape as the gaps this rule exists to find. Revoked; the slot is +// accounted for like every other contracted slot. +const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager'].join('|'); // Exported so `scripts/check-slot-lookup-ratchet.mjs` can identify THIS rule's // reports among the other `no-restricted-syntax` rules, by exact message — diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts index c92d2f3a07..d34456941f 100644 --- a/packages/client/src/client.batch-transaction.test.ts +++ b/packages/client/src/client.batch-transaction.test.ts @@ -33,6 +33,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; +import type { IHttpServer } from '@objectstack/spec/contracts'; describe('data.batchTransaction (live Hono, #1604)', () => { let baseUrl: string; @@ -103,8 +104,8 @@ describe('data.batchTransaction (live Hono, #1604)', () => { await ql.syncObjectSchema('project'); await ql.syncObjectSchema('task'); - const httpServer = kernel.getService('http.server'); - baseUrl = `http://localhost:${httpServer.getPort()}`; + const httpServer = kernel.getService('http.server'); + baseUrl = `http://localhost:${httpServer.getPort!()}`; client = new ObjectStackClient({ baseUrl }); }, 30_000); diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts index f28ba46c6a..460d1d99aa 100644 --- a/packages/client/src/client.environment-scoping.test.ts +++ b/packages/client/src/client.environment-scoping.test.ts @@ -20,6 +20,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; +import type { IHttpServer } from '@objectstack/spec/contracts'; describe('Project-scoped REST routing (live Hono)', () => { let baseUrl: string; @@ -82,8 +83,8 @@ describe('Project-scoped REST routing (live Hono)', () => { // write fails with `no such table`. await ql.syncObjectSchema('task'); - const httpServer = kernel.getService('http.server'); - const port = httpServer.getPort(); + const httpServer = kernel.getService('http.server'); + const port = httpServer.getPort!(); baseUrl = `http://localhost:${port}`; }, 30_000); diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index 5c5f26e863..c35c9ef8ae 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -5,6 +5,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; +import type { IHttpServer } from '@objectstack/spec/contracts'; describe('ObjectStackClient (with Hono Server)', () => { let baseUrl: string; @@ -130,8 +131,8 @@ describe('ObjectStackClient (with Hono Server)', () => { await ql.syncObjectSchema('customer'); // 5. Get Port from Service - const httpServer = kernel.getService('http.server'); - const port = httpServer.getPort(); + const httpServer = kernel.getService('http.server'); + const port = httpServer.getPort!(); baseUrl = `http://localhost:${port}`; console.log(`Test server running at ${baseUrl}`); diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index d5cfdc548d..c681e0716e 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -109,6 +109,10 @@ const ARTIFACT_FIELD_TO_TYPE: Record = { // callers below and for `view-expand.test.ts`. export { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +/** `IHttpServer` plus the framework-specific raw-app handle this route needs. */ +type HttpServerWithRawApp = IHttpServer & { getRawApp?(): any }; export interface MetadataPluginOptions { rootDir?: string; @@ -405,8 +409,14 @@ export class MetadataPlugin implements Plugin { // Production deployments simply won't have a CLI POSTing to this // endpoint and won't surface the route to clients. try { - const httpServer = ctx.getService('http-server') - ?? ctx.getService('http.server'); + // [#4251] Both names are the SAME instance — plugin-hono-server and + // qa/node-plugin each register it twice, two lines apart. Typed to + // the contract now that the `http.server` exemption is revoked; + // `getRawApp()` is not on `IHttpServer` (framework-agnostic by + // design, the raw app is the framework's own handle), so that one + // member is named here — the third consumer to need it. + const httpServer = ctx.getService('http-server') + ?? ctx.getService('http.server'); if (httpServer && typeof httpServer.getRawApp === 'function') { const { registerMetadataHmrRoutes } = await import('./routes/hmr-routes.js'); const hub = registerMetadataHmrRoutes(httpServer.getRawApp(), this.manager); diff --git a/packages/qa/http-conformance/src/conformance.integration.test.ts b/packages/qa/http-conformance/src/conformance.integration.test.ts index 03ea6c9110..773ac219ce 100644 --- a/packages/qa/http-conformance/src/conformance.integration.test.ts +++ b/packages/qa/http-conformance/src/conformance.integration.test.ts @@ -22,6 +22,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createDispatcherPlugin, createRestApiPlugin } from '@objectstack/runtime'; import { NodeServerPlugin } from './node-plugin.js'; +import type { IHttpServer } from '@objectstack/spec/contracts'; type AdapterCase = { label: 'node' | 'hono'; @@ -97,8 +98,8 @@ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean // a 404 OBJECT_NOT_FOUND — a routing-shaped symptom for a DDL-shaped cause. await ql.syncObjectSchema('task'); - const httpServer = kernel.getService('http.server'); - return { kernel, base: `http://127.0.0.1:${httpServer.getPort()}` }; + const httpServer = kernel.getService('http.server'); + return { kernel, base: `http://127.0.0.1:${httpServer.getPort!()}` }; } describe.each(ADAPTERS)('IHttpServer conformance on $label adapter', ({ makePlugin }) => { diff --git a/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts index 2b34ddf1dc..b33e679287 100644 --- a/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts @@ -5,6 +5,7 @@ import { LiteKernel } from '@objectstack/core'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import type { IHttpServer } from '@objectstack/spec/contracts'; /** * Integration regression for framework #2217 seam #2. @@ -29,8 +30,8 @@ describe('GET /ready over a real HTTP server (integration)', () => { await kernel.bootstrap(); - const httpServer = kernel.getService('http.server'); - baseUrl = `http://127.0.0.1:${httpServer.getPort()}`; + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; }, 30_000); afterAll(async () => { diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index 06f8ca7d11..80c6ad7048 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -10,6 +10,7 @@ import { MessagingServicePlugin, MessagingService } from '@objectstack/service-m import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; +import type { IHttpServer } from '@objectstack/spec/contracts'; /** * End-to-end regression for framework #3362 (`#3354 not effective on hono`). @@ -89,8 +90,8 @@ describe('in-app notifications over a real hono server (integration, #3362)', () await kernel.bootstrap(); - const httpServer = kernel.getService('http.server'); - baseUrl = `http://127.0.0.1:${httpServer.getPort()}`; + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; messaging = kernel.getService('notification'); }, 30_000); diff --git a/packages/runtime/src/route-parity.integration.test.ts b/packages/runtime/src/route-parity.integration.test.ts index 8f40358b20..6d1d63d376 100644 --- a/packages/runtime/src/route-parity.integration.test.ts +++ b/packages/runtime/src/route-parity.integration.test.ts @@ -6,6 +6,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { isMcpServerEnabled } from '@objectstack/types'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import type { IHttpServer } from '@objectstack/spec/contracts'; /** * The statuses that mean "advertised but not actually served" — 404 (route not @@ -126,8 +127,8 @@ async function bootServe(stubOpts: { notification?: boolean; mcp?: boolean } = { kernel.use(new HonoServerPlugin({ port: 0, cors: false })); kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: true })); await kernel.bootstrap(); - const httpServer = kernel.getService('http.server'); - const baseUrl = `http://127.0.0.1:${httpServer.getPort()}`; + const httpServer = kernel.getService('http.server'); + const baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; return { kernel, baseUrl }; } diff --git a/packages/runtime/src/server-timing.integration.test.ts b/packages/runtime/src/server-timing.integration.test.ts index b978e46ea4..5ee21d66df 100644 --- a/packages/runtime/src/server-timing.integration.test.ts +++ b/packages/runtime/src/server-timing.integration.test.ts @@ -5,6 +5,7 @@ import { LiteKernel } from '@objectstack/core'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { SqlDriver } from '@objectstack/driver-sql'; import { allowPerfDisclosure } from '@objectstack/observability'; +import type { IHttpServer } from '@objectstack/spec/contracts'; /** * End-to-end regression for the Server-Timing `db` span (issue #2408). @@ -41,7 +42,7 @@ describe('Server-Timing db span over a real HTTP server (integration)', () => { kernel.use(new HonoServerPlugin({ port: 0, serverTiming: true, cors: false })); await kernel.bootstrap(); - const httpServer = kernel.getService('http.server'); + const httpServer = kernel.getService('http.server'); // A route whose handler runs two real queries through the driver — the // same shape as a data-API list that does a find + a follow-up lookup. httpServer.get('/widgets', async (_req: any, res: any) => { @@ -57,7 +58,7 @@ describe('Server-Timing db span over a real HTTP server (integration)', () => { allowPerfDisclosure(); res.json({ all: all.length, one: one.length }); }); - baseUrl = `http://127.0.0.1:${httpServer.getPort()}`; + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; }, 30_000); afterAll(async () => { diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index a9319f889d..d072e6963e 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -7,7 +7,6 @@ "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 17, "packages/core/examples/kernel-features-example.ts": 5, "packages/metadata-protocol/src/plugin.ts": 1, - "packages/metadata/src/plugin.ts": 1, "packages/objectql/src/plugin.integration.test.ts": 23, "packages/objectql/src/plugin.ts": 7, "packages/plugins/plugin-approvals/src/approvals-plugin.ts": 9,