feat: add nuxt sdk (#260)

* wip

* fix: improve api route for nuxt
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-07 10:28:11 +01:00
committed by GitHub
parent 3bd1f99d28
commit 1f088d2208
13 changed files with 4990 additions and 215 deletions

View File

@@ -0,0 +1,5 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
failOnWarn: false,
});

View File

@@ -0,0 +1,2 @@
// This file is for development - the built version uses src/module.ts
export { default, type ModuleOptions } from './src/module';

View File

@@ -0,0 +1,40 @@
{
"name": "@openpanel/nuxt",
"version": "0.0.2-local",
"type": "module",
"main": "./dist/module.mjs",
"exports": {
".": {
"types": "./dist/module.d.mts",
"import": "./dist/module.mjs"
}
},
"files": ["dist"],
"config": {
"transformPackageJson": false,
"transformEnvs": false
},
"scripts": {
"build": "npx nuxt-module-build build",
"dev:prepare": "npx nuxt-module-build build --stub",
"prepack": "npx nuxt-module-build build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/web": "workspace:1.0.6-local"
},
"peerDependencies": {
"h3": "^1.0.0",
"nuxt": "^3.0.0 || ^4.0.0"
},
"devDependencies": {
"@nuxt/kit": "^3.0.0",
"@nuxt/module-builder": "^1.0.2",
"@nuxt/types": "^2.18.1",
"@openpanel/tsconfig": "workspace:*",
"@types/node": "catalog:",
"@vue/runtime-core": "^3.5.25",
"typescript": "catalog:",
"unbuild": "^3.6.1"
}
}

View File

@@ -0,0 +1,56 @@
import {
addImports,
addPlugin,
addServerHandler,
createResolver,
defineNuxtModule,
} from '@nuxt/kit';
import type { ModuleOptions } from './types';
export type { ModuleOptions };
export default defineNuxtModule<ModuleOptions>({
meta: {
name: '@openpanel/nuxt',
configKey: 'openpanel',
},
defaults: {
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
trackHashChanges: false,
disabled: false,
proxy: false, // Disabled by default
},
setup(options, nuxt) {
const resolver = createResolver(import.meta.url);
// If proxy is enabled, override apiUrl to use the proxy route
if (options.proxy) {
options.apiUrl = '/api/openpanel';
}
// Expose options to runtime config
nuxt.options.runtimeConfig.public.openpanel = options;
// Add client plugin (creates OpenPanel instance)
addPlugin({
src: resolver.resolve('./runtime/plugin.client'),
mode: 'client',
});
// Only register server proxy handler if proxy is enabled
if (options.proxy) {
addServerHandler({
route: '/api/openpanel/**',
handler: resolver.resolve('./runtime/server/api/[...openpanel]'),
});
}
// Auto-import the useOpenPanel composable
addImports({
name: 'useOpenPanel',
from: resolver.resolve('./runtime/composables/useOpenPanel'),
});
},
});

View File

@@ -0,0 +1,6 @@
import { useNuxtApp } from '#app';
export function useOpenPanel() {
const { $openpanel } = useNuxtApp();
return $openpanel;
}

View File

@@ -0,0 +1,30 @@
import { OpenPanel } from '@openpanel/web';
import { defineNuxtPlugin, useRuntimeConfig } from '#app';
import type { ModuleOptions } from '../types';
declare module '#app' {
interface NuxtApp {
$openpanel: OpenPanel;
}
}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$openpanel: OpenPanel;
}
}
export default defineNuxtPlugin({
name: 'openpanel',
parallel: true,
setup() {
const config = useRuntimeConfig().public.openpanel as ModuleOptions;
const op = new OpenPanel(config);
return {
provide: {
openpanel: op,
},
};
},
});

View File

@@ -0,0 +1,90 @@
import {
type EventHandlerRequest,
type H3Event,
createError,
defineEventHandler,
getHeader,
getRequestIP,
getRequestURL,
readBody,
setResponseStatus,
} from 'h3';
const API_URL = 'https://api.openpanel.dev';
function getClientHeaders(event: H3Event<EventHandlerRequest>): Headers {
const headers = new Headers();
// Get IP from multiple possible headers (like Next.js does)
const ip =
getHeader(event, 'cf-connecting-ip') ||
getHeader(event, 'x-forwarded-for')?.split(',')[0] ||
getRequestIP(event);
headers.set('Content-Type', 'application/json');
headers.set(
'openpanel-client-id',
getHeader(event, 'openpanel-client-id') || '',
);
// Construct origin: browsers send Origin header for POST requests and cross-origin requests,
// but not for same-origin GET requests. Fallback to constructing from request URL.
const origin =
getHeader(event, 'origin') ||
(() => {
const url = getRequestURL(event);
return `${url.protocol}//${url.host}`;
})();
headers.set('origin', origin);
headers.set('User-Agent', getHeader(event, 'user-agent') || '');
if (ip) {
headers.set('openpanel-client-ip', ip);
}
return headers;
}
async function handleApiRoute(
event: H3Event<EventHandlerRequest>,
apiPath: string,
) {
try {
const res = await fetch(`${API_URL}${apiPath}`, {
method: event.method,
headers: getClientHeaders(event),
body:
event.method === 'POST'
? JSON.stringify(await readBody(event))
: undefined,
});
setResponseStatus(event, res.status);
if (res.headers.get('content-type')?.includes('application/json')) {
return res.json();
}
return res.text();
} catch (e) {
throw createError({
statusCode: 500,
message: 'Failed to proxy request',
data: e instanceof Error ? e.message : String(e),
});
}
}
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const pathname = url.pathname;
// Handle API routes: /track/*
const apiPathMatch = pathname.indexOf('/track');
if (apiPathMatch === -1) {
throw createError({ statusCode: 404, message: 'Not found' });
}
const apiPath = pathname.substring(apiPathMatch);
return handleApiRoute(event, apiPath);
});

20
packages/sdks/nuxt/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
import type { OpenPanel, OpenPanelOptions } from '@openpanel/web';
export interface ModuleOptions extends OpenPanelOptions {
proxy?: boolean;
}
declare module '#app' {
interface NuxtApp {
$openpanel: OpenPanel;
}
}
declare module 'vue' {
interface ComponentCustomProperties {
$openpanel: OpenPanel;
}
}
// biome-ignore lint/complexity/noUselessEmptyExport: we need to export an empty object to satisfy the type checker
export {};

View File

@@ -0,0 +1,17 @@
{
"extends": "@openpanel/tsconfig/sdk.json",
"compilerOptions": {
"incremental": false,
"outDir": "dist",
"paths": {
"#app": [
"./node_modules/nuxt/dist/app/index"
]
},
"types": [
"@types/node",
"@nuxt/types"
]
},
"exclude": ["dist"]
}