Skip to content

feat(elysia): add isolated Elysia 2 integration - #12

Open
pileskyd wants to merge 1 commit into
logtide-dev:mainfrom
pileskyd:feat/elysia-v2-export
Open

feat(elysia): add isolated Elysia 2 integration#12
pileskyd wants to merge 1 commit into
logtide-dev:mainfrom
pileskyd:feat/elysia-v2-export

Conversation

@pileskyd

Copy link
Copy Markdown

Summary

Adds an isolated Elysia 2 integration at @logtide/elysia/next while preserving the existing Elysia 1.4 contract at @logtide/elysia.

Elysia 2 is currently published under the next npm tag (2.0.0-beta.4) and introduces breaking lifecycle and type changes. The two adapters are therefore compiled and tested against separate npm aliases instead of sharing framework types.

Changes

  • @logtide/elysia/next: new Elysia 2 entry point using the request, afterHandle, error, and afterResponse lifecycle APIs.
  • Isolated contracts: the root export continues to compile against Elysia 1.4 (elysia@latest), while the preview export compiles against elysia@next through the elysia-next development alias.
  • Correct final response telemetry: Elysia 2 spans are finalized in afterResponse, after downstream response/error mapping has selected the actual status. status() wrappers, Web Response, named statuses, mapped errors, and late lifecycle failures are covered.
  • Trace propagation: traceparent is injected for both successful and error responses.
  • Packaging: added ESM, CJS, .d.ts, and .d.cts targets for ./next. Published runtime and declaration output references the consumer peer package elysia, never the development-only alias.
  • Compatibility fixture: added test-app-elysia-v2, including typechecking and mock-server smoke coverage through the built @logtide/elysia/next export.
  • Documentation: updated the package README, root README, and changelog with the version-specific imports and lifecycle contracts.

Tests

  • pnpm build
  • pnpm typecheck
  • pnpm test
  • pnpm test:smoke
  • @logtide/elysia: 32 tests pass (11 Elysia 1.4 + 21 Elysia 2)
  • test-app-elysia-v2: typecheck and 3 smoke tests pass
  • Generated ESM/CJS runtime files and both declaration modes were checked to import elysia rather than elysia-next.

@Polliog Polliog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 findings, verified against the published elysia@2.0.0-beta.4 dist, details inline. The four in plugin-next.ts matter most (span leak on abort, misreported early returns, statusFrom() body/status confusion, throw status(4xx) logged as error); the rest are packaging/tooling issues.

}
})
.afterResponse(({ request, responseValue, set }) => {
const client = hub.getClient();

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Span leak on client abort. Elysia 2.0.0-beta.4 skips afterResponse when the client aborts (both fetch.mjs and the JIT lanes return early), so finishSpan never runs and every aborted request leaves a Span forever in the unbounded activeSpans Map. Fix: also finalize on request.signal abort, or sweep stale entries.

if (!client || !ctx || ctx.finalized) return;

const status = statusFrom(set.status) ?? statusFrom(responseValue) ?? 200;
const { scope, spanId, startTime } = ctx;

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early returns recorded as 200/'ok' and lose traceparent. When another plugin's request hook returns early (CORS 204, auth 401, rate-limit 429), Elysia never assigns context.responseValue nor writes back set.status, so the span falls back to 200/'ok'. That lane also bypasses afterHandle, so the traceparent header is never set.

}

function statusFrom(value: unknown): number | undefined {
if (typeof value === 'number') return value;

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

statusFrom() treats response bodies as HTTP statuses. () => 503 produces a healthy 200 response (503 becomes the body), but the span records http.status_code=503 and status 'error'; () => 'Unauthorized' records 401. Also value in StatusMap walks the prototype chain, so a body like 'toString' resolves to a Function that ends up in span attributes. Fix: rely on set.status (already normalized by afterResponse time) plus Response/ElysiaStatus instances, drop the number and StatusMap-string branches.

set.headers.traceparent = createTraceparent(ctx.traceId, ctx.spanId, true);
})
.error(({ request, error, set }) => {
const client = hub.getClient();

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throw status(4xx) treated as failure. A thrown ElysiaStatus goes through user .error() hooks first and has no message, so it gets logged as '{"code":401,"response":"Unauthorized"}' at error level, and ctx.errored forces the span to 'error'. Apps using throw status(401) guards flood LogTide with errors for routine rejections. Fix: skip or downgrade ElysiaStatus with code < 500.

},
"peerDependencies": {
"elysia": ">=1.0.0"
"elysia": ">=1.4.0 <2.0.0 || >=2.0.0-beta.4 <3.0.0"

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peer range wrong at both ends. It drops elysia 1.0-1.3 users (undocumented breaking change, the v1 plugin needs nothing newer), and npm semver only matches prereleases on the same major.minor.patch, so >=2.0.0-beta.4 <3.0.0 rejects future betas like 2.0.1-beta.1 or 2.1.0-beta.0.

"devDependencies": {
"@sinclair/typebox": "^0.34.48",
"elysia": "^1.2.0",
"elysia": "npm:elysia@latest",

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floating dist-tags decide what gets built and tested. Once Elysia promotes 2.0.0 to latest, any lockfile refresh retargets the v1 plugin's build/tests/dts at Elysia 2, and next will drift to 2.1/3.0 betas. Pin ^1.4.29 and an exact or tilde beta instead.

Comment thread package.json
"@hono/node-server": ">=1.19.10",
"defu": ">=6.1.5",
"elysia": ">=1.4.27",
"elysia@<1.4.27": "1.4.29",

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security override no longer guards anything. pnpm's pkg@range selector only matches semver-range specifiers, so elysia@<1.4.27 does not apply to npm:elysia@latest/@next, and the exact 1.4.29 pin will not pick up a future 1.4.30 fix. Suggest "elysia@<2": ">=1.4.27 <2".

import { defineConfig } from 'vitest/config';

export default defineConfig({
resolve: {

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixture only works thanks to resolve.dedupe. The built dist/next.js imports bare 'elysia', which outside vitest (bun run src/app.ts, tsx) resolves to the package's own elysia 1.4.29 devDep, so a v1 plugin gets used in a v2 app. Related: the instanceof ElysiaStatus/Response checks break whenever two elysia copies coexist; duck-typing .code/.status would be robust.

await Promise.all(
['dist/next.d.ts', 'dist/next.d.cts'].map(async (path) => {
const declaration = await readFile(path, 'utf8');
const patched = declaration.replaceAll('elysia-next', 'elysia');

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile in both directions. The blind replaceAll rewrites any future 'elysia-next' occurrence in the dts output, and the throw-on-no-match fails the whole build if tsup's dts shape changes (dts.resolve, different chunking, or an explicit return type eliding the import). Promise.all can also leave dist half-patched.

Comment thread pnpm-lock.yaml
content-disposition: 0.5.4
content-type: 1.0.5
cookie: 0.7.2
cookie: 1.1.1

@Polliog Polliog Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side effect: express 4 now resolves cookie 1.1.1. The unbounded root override "cookie": ">=0.7.0" bumped express@4.22.1 from cookie 0.7.2 to 1.1.1 (express 4 pins ~0.7.1 upstream), so the express test apps run a graph no real express 4 user has. express@5.2.1 stays on 0.7.2 in this same diff, confirming it is accidental. Bound the override instead, e.g. cookie@<0.7.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants