Consume a framework-independent SDK with an application-provided Vue client and caller-owned cleanup.
Vue
Import operations from your SDK or shared definition module. The Vue application configures and provides the client; the SDK package can stay independent of Vue. Components and composables call those operations and own the work they start. The client from injectClient() is passed into the operation.
These plugins are optional helpers for providing and reading an existing Client. Import a fixed application-owned client, pass a Client explicitly to reusable logic, use framework injection for tree-level defaults and overrides, or choose your own organization. SDK definitions do not require these plugins.
Wire an existing @defjs/core client into Vue. You get a plugin, an injection key, and injectClient(). The package does not create clients, cache results, retry operations, or close transport resources on unmount. The injected value is the DerivableClient that was provided; call it with request options where the operation runs to derive a Client value. Do not use it as a watch or computed source, because those call a function. A derived Client value is not accepted by the plugin or injectClient.
Basic Setup
Install @defjs/core, @defjs/vue, and Vue 3.2+ (the plugin only needs provide / inject, but the examples use <script setup> and onScopeDispose, which arrived in 3.2). ESM:
bun add @defjs/core @defjs/vue vue
Create the client, install the plugin, then fetch with abort-on-change:
import { createClient, withEndpoint } from "@defjs/core";
import { createClientPlugin } from "@defjs/vue";
import { createApp } from "vue";
import App from "./App.vue";
const client = createClient(withEndpoint("https://api.example.com"));
const app = createApp(App);
app.use(createClientPlugin(client));
app.mount("#app");<script setup lang="ts">
import { defineRequest, struct } from "@defjs/core";
import { injectClient } from "@defjs/vue";
import { ref, watch } from "vue";
const props = defineProps<{ id: number }>();
const client = injectClient();
const name = ref("Loading...");
const getUser = defineRequest({
method: "GET",
path: "/users/:id",
input: struct.object({ id: struct.number() }),
builder: (input) => ({ path: { id: input.id } }),
output: struct.object({ name: struct.string() }),
});
watch(
() => props.id,
(id, _previousId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
void getUser(client({ signal: controller.signal }), { id }).then(
([error, user]) => {
if (controller.signal.aborted) return;
if (error) {
name.value = "Unable to load user.";
return;
}
name.value = user.name;
},
);
},
{ immediate: true },
);
</script>
<template>
<span>{{ name }}</span>
</template>createClientPlugin(client) provides the exact object you pass. No clone, no disposal hook. Configure core options and interceptors when you create the client.
onCleanup runs before the watcher re-runs and when it stops. Register it before starting async work. The error-first tuple stays application data.
Inject and override
injectClient(key) reads the nearest provider for that key; omitted or undefined uses HTTP_CLIENT. Call in a valid Vue injection context: synchronous setup() or app.runWithContext() (Vue 3.3+). The latter supplies only synchronous app-level context, not subtree overrides or context across ordinary await. Compiler-restored contexts after top-level await in <script setup> remain valid.
For a custom InjectionKey<Client>, use app.provide(key, client) or an ancestor’s provide(key, client). Share the same Symbol object; matching descriptions do not make keys equal. createClientPlugin(client) still takes only the client and provides HTTP_CLIENT.
Throws an Error without an active injection context or a client for the selected key. A missing custom key never falls back to HTTP_CLIENT; installing the plugin alone does not establish an injection context.
An application function that only retrieves a client may use explicitClient ?? injectClient(): only the fallback branch needs injection context. Other lifecycle APIs in the same composable retain their own context requirements. Read the client in a valid context before async work and reuse the object.
Override a subtree with Vue’s provide(HTTP_CLIENT, childClient):
<script setup lang="ts">
import { createClient, withEndpoint } from "@defjs/core";
import { HTTP_CLIENT, injectClient } from "@defjs/vue";
import { defineComponent, h, provide } from "vue";
const childClient = createClient(withEndpoint("https://tenant.example.com"));
const Child = defineComponent({
setup() {
const client = injectClient();
return () =>
h(
"span",
client === childClient
? "Child client is provided"
: "Unexpected client",
);
},
});
provide(HTTP_CLIENT, childClient);
</script>
<template>
<Child />
</template>Nearest provider wins. Descendants get childClient; siblings outside the subtree keep the app-level client. The providing component itself still reads from its ancestors.
Own HTTP work outside a watcher
For work started by a composable or component outside a watcher, use AbortController + onScopeDispose. Abort startup and active work; check the signal before assigning reactive state. A plugin or injection scope does not infer who owns an operation. For list pages, call the operation from Vue Query (or similar) — @defjs/vue stays DI only and does not ship useRequest.
When a scope owns a client, keep it request-independent for browser-wide reuse. If it captures headers, cookies, users, tenants, or credentials, create it in the relevant app/SSR request boundary and provide that instance there.
Clean up realtime scope
Close a stream or session even when the scope disappears mid-connect. Abort startup, close a late-arriving handle, consume the single iterator, await the terminal promise:
<script setup lang="ts">
import { defineEventStream, struct, type EventStreamHandle } from "@defjs/core";
import { injectClient } from "@defjs/vue";
import { onScopeDispose, ref } from "vue";
const client = injectClient();
const messages = ref<{ id: number; text: string }[]>([]);
let nextId = 0;
const notifications = defineEventStream({
maxBufferSize: 64 * 1024,
maxQueueSize: 100,
path: "/notifications",
events: { message: struct.string() },
});
const controller = new AbortController();
let disposed = false;
let stream: EventStreamHandle<unknown> | undefined;
const stop = () => {
disposed = true;
controller.abort();
stream?.close("scope-disposed");
};
onScopeDispose(stop);
void (async () => {
const [error, nextStream] = await notifications(
client({ signal: controller.signal }),
);
if (error) return;
stream = nextStream;
if (disposed) {
nextStream.close("scope-disposed");
await nextStream.closed;
return;
}
try {
for await (const event of nextStream) {
messages.value.push({ id: nextId++, text: event.data });
if (messages.value.length > 100) messages.value.shift();
}
} catch (cause) {
if (!disposed) console.error("notifications failed", cause);
} finally {
nextStream.close("scope-finished");
await nextStream.closed;
}
})();
</script>
<template>
<ul>
<li v-for="message in messages" :key="message.id">{{ message.text }}</li>
</ul>
</template>Fatal stream errors and queue overflow reject the iterator, so the loop has a catch; otherwise that rejection is unhandled. The example keeps only the last 100 messages and keys rows by an id, because identical payloads would give duplicate keys. Stopping the scope ends iteration normally for SSE; an aborted WebSocket session instead rejects receive with AbortError, so guard the catch the same way.
WebSocket: same sequence — abort prep, close a late session, consume session.receive, unsubscribe onStateChange / onRuntimeError, close, await session.closed. Keep cleanup idempotent; disposal and iterator completion can meet.
SSR scope
createClientPlugin(client) provides one instance to one Vue app. In the browser, share it when endpoint, interceptors, and captured state are safe to share. During SSR, create and install a separate client per request when headers, cookies, users, tenants, or credentials differ.
App unmount, plugin removal, and component scope disposal do not abort HTTP, close SSE/WebSocket, unsubscribe listeners, or dispose the core client. The owner that starts the work must finish it.
Reference
Public exports from @defjs/vue:
import { HTTP_CLIENT, createClientPlugin, injectClient } from "@defjs/vue";
type VueApi = {
HTTP_CLIENT: typeof HTTP_CLIENT;
createClientPlugin: typeof createClientPlugin;
injectClient: typeof injectClient;
};
const api: VueApi = { HTTP_CLIENT, createClientPlugin, injectClient };
void api;HTTP_CLIENT—InjectionKey<Client>for nativeprovide/injectcreateClientPlugin(client)— VuePluginthat provides that clientinjectClient— nearest client for the selected key (defaultHTTP_CLIENT); throws if missing
Create clients and options in @defjs/core. See Client, HTTP, Interceptors, SSE, and WebSocket.