This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-11 12:34:35 +02:00
commit 903fd155c3
40 changed files with 1385 additions and 0 deletions

176
.gitignore vendored Normal file
View File

@@ -0,0 +1,176 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# mixan
Mixan is a simple analytics tool for logging events on web and react-native. My goal is to make a minimal mixpanel copy with the most basic features (for now).
## @mixan/sdk
For pushing events
### Install
- npm: `npm install @mixan/sdk`
- pnpm: `pnpm add @mixan/sdk`
- yarn: `yarn add @mixan/sdk`
### Usage
```ts
import { Mixan } from '@mixan/sdk';
const mixan = new Mixan({
clientSecret: '9fb405d2-7e16-489f-980c-67b25a6eab97',
url: 'http://localhost:8080',
batchInterval: 10000,
verbose: false
})
mixan.setUser({
id: 'id',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@gmail.com',
properties: {} // any properties
})
// will upsert 'app_open' on user property and increment it
mixan.increment('app_open')
// will upsert 'app_open' on user property and increment it by 10
mixan.increment('app_open', 10)
// will upsert 'app_open' on user property and decrement it by 2
mixan.decrement('app_open', 2)
// send a sign_in event
mixan.event('sign_in')
// send a sign_in event with properties
mixan.event('sign_in', {
provider: 'gmail'
})
// short hand for 'screen_view', can also take any properties
mixan.screenView('Profile', {
id: '123',
// any other properties, url, public
})
```
## @mixan/backend
Self hosted service for collecting all events. Dockerfile and GUI will be added soon.

15
apps/backend/@types/express/index.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
export {}
declare global {
// metadata-scraper relies on this type
type Element = any
// add context to request
namespace Express {
interface Request {
client: {
project_id: string
}
}
}
}

27
apps/backend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "bun src/app.ts",
"dev": "bun --watch src/app.ts",
"codegen": "bunx prisma generate"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@mixan/types": "workspace:*",
"@prisma/client": "^5.4.2",
"express": "^4.18.2",
"morgan": "^1.10.0",
"prisma": "^5.4.2",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/express": "^4.17.18",
"@types/morgan": "^1.9.6",
"bun-types": "^1.0.5-canary.20231009T140142"
}
}

View File

@@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "organizations" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "events" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"properties" JSONB NOT NULL,
"project_id" UUID NOT NULL,
CONSTRAINT "events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "profiles" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"properties" JSONB NOT NULL,
"project_id" UUID NOT NULL,
CONSTRAINT "profiles_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "events" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "profiles" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `name` on the `profiles` table. All the data in the column will be lost.
- Added the required column `profile_id` to the `profiles` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "profiles" DROP COLUMN "name",
ADD COLUMN "profile_id" TEXT NOT NULL;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "profiles" ADD COLUMN "avatar" TEXT,
ADD COLUMN "email" TEXT,
ADD COLUMN "first_name" TEXT,
ADD COLUMN "last_name" TEXT;

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "clients" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"project_id" UUID NOT NULL,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "clients" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `profile_id` to the `events` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "events" ADD COLUMN "profile_id" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "events" DROP CONSTRAINT "events_profile_id_fkey";
-- AlterTable
ALTER TABLE "events" ALTER COLUMN "profile_id" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `profile_id` on the `profiles` table. All the data in the column will be lost.
- Added the required column `external_id` to the `profiles` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "profiles" DROP COLUMN "profile_id",
ADD COLUMN "external_id" TEXT NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[project_id,external_id]` on the table `profiles` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "profiles_project_id_external_id_key" ON "profiles"("project_id", "external_id");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,100 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Organization {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
projects Project[]
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("organizations")
}
model Project {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
organization_id String @db.Uuid
organization Organization @relation(fields: [organization_id], references: [id])
events Event[]
profiles Profile[]
clients Client[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("projects")
}
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
email String
password String
organization_id String @db.Uuid
organization Organization @relation(fields: [organization_id], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("users")
}
model Event {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
properties Json
project_id String @db.Uuid
project Project @relation(fields: [project_id], references: [id])
profile_id String? @db.Uuid
profile Profile? @relation(fields: [profile_id], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("events")
}
model Profile {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
external_id String
first_name String?
last_name String?
email String?
avatar String?
properties Json
project_id String @db.Uuid
project Project @relation(fields: [project_id], references: [id])
events Event[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([project_id, external_id])
@@map("profiles")
}
model Client {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
secret String
project_id String @db.Uuid
project Project @relation(fields: [project_id], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("clients")
}

41
apps/backend/src/app.ts Normal file
View File

@@ -0,0 +1,41 @@
import express from "express";
import events from './routes/events'
import profiles from './routes/profiles'
import { authMiddleware } from "./middlewares/auth";
import morgan from 'morgan'
import { db } from "./db";
import { hashPassword } from "./services/password";
import { v4 as uuid } from 'uuid';
const app = express();
const port = 8080;
app.use(morgan('tiny'))
app.use(express.json());
app.use(express.json());
app.use(authMiddleware)
app.use(events)
app.use(profiles)
app.get("/ping", (req, res) => res.json("pong"));
app.listen(port, () => {
console.log(`Listening on port ${port}...`);
});
// async function main() {
// const secret = uuid()
// await db.client.create({
// data: {
// project_id: 'eed345ae-2772-42e5-b989-e36e09c5febc',
// name: 'test',
// secret: await hashPassword(secret),
// }
// })
// console.log('Your secret is', secret);
// }
// main()

3
apps/backend/src/db.ts Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();

View File

@@ -0,0 +1,33 @@
import { NextFunction, Request, Response } from "express"
import { db } from "../db"
import { verifyPassword } from "../services/password"
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const secret = req.headers['mixan-client-secret'] as string | undefined
if(!secret) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Missing client secret',
})
}
const client = await db.client.findFirst({
where: {
secret,
},
})
if(!client) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'Invalid client secret',
})
}
req.client = {
project_id: client.project_id,
}
next()
}

View File

@@ -0,0 +1,13 @@
import { MixanIssue, MixanIssuesResponse } from "@mixan/types";
export function issues(arr: Array<MixanIssue>): MixanIssuesResponse {
return {
issues: arr.map((item) => {
return {
field: item.field,
message: item.message,
value: item.value,
};
})
}
}

View File

@@ -0,0 +1,8 @@
import { MixanResponse } from "@mixan/types";
export function success<T>(result?: T): MixanResponse<T | null> {
return {
result: result || null,
status: 'ok'
}
}

View File

@@ -0,0 +1,34 @@
import {Router} from 'express'
import { db } from '../db';
import { MixanRequest } from '../types/express';
import { EventPayload } from '@mixan/types';
import { getEvents, getProfileIdFromEvents } from '../services/event';
import { success } from '../responses/success';
const router = Router();
type PostRequest = MixanRequest<Array<EventPayload>>
router.get('/events', async (req, res) => {
const events = await getEvents(req.client.project_id)
res.json(success(events))
})
router.post('/events', async (req: PostRequest, res) => {
const projectId = req.client.project_id
const profileId = await getProfileIdFromEvents(projectId, req.body)
await db.event.createMany({
data: req.body.map(event => ({
name: event.name,
properties: event.properties,
createdAt: event.time,
project_id: projectId,
profile_id: profileId,
}))
})
res.status(201).json(success())
})
export default router

View File

@@ -0,0 +1,133 @@
import { Router } from 'express'
import { db } from '../db'
import { MixanRequest } from '../types/express'
import {
createProfile,
getProfileByExternalId,
updateProfile,
} from '../services/profile'
import {
ProfileDecrementPayload,
ProfileIncrementPayload,
ProfilePayload,
} from '@mixan/types'
import { issues } from '../responses/errors'
import { success } from '../responses/success'
const router = Router()
type PostRequest = MixanRequest<ProfilePayload>
router.get('/profiles', async (req, res) => {
res.json(success(await db.profile.findMany({
where: {
project_id: req.client.project_id,
},
})))
})
router.post('/profiles', async (req: PostRequest, res) => {
const body = req.body
const projectId = req.client.project_id
const profile = await getProfileByExternalId(projectId, body.id)
if (profile) {
await updateProfile(projectId, body.id, body, profile)
} else {
await createProfile(projectId, body)
}
res.status(profile ? 200 : 201).json(success())
})
router.post(
'/profiles/increment',
async (req: MixanRequest<ProfileIncrementPayload>, res) => {
const body = req.body
const projectId = req.client.project_id
const profile = await getProfileByExternalId(projectId, body.id)
if (profile) {
const existingProperties = (
typeof profile.properties === 'object' ? profile.properties || {} : {}
) as Record<string, number>
const value =
body.name in existingProperties ? existingProperties[body.name] : 0
const properties = {
...existingProperties,
[body.name]: value + body.value,
}
if (typeof value !== 'number') {
return res.status(400).json(
issues([
{
field: 'name',
message: 'Property is not a number',
value,
},
])
)
}
await db.profile.updateMany({
where: {
external_id: String(body.id),
project_id: req.client.project_id,
},
data: {
properties,
},
})
}
res.status(200).json(success())
}
)
router.post(
'/profiles/decrement',
async (req: MixanRequest<ProfileDecrementPayload>, res) => {
const body = req.body
const projectId = req.client.project_id
const profile = await getProfileByExternalId(projectId, body.id)
if (profile) {
const existingProperties = (
typeof profile.properties === 'object' ? profile.properties || {} : {}
) as Record<string, number>
const value =
body.name in existingProperties ? existingProperties[body.name] : 0
if (typeof value !== 'number') {
return res.status(400).json(
issues([
{
field: 'name',
message: 'Property is not a number',
value,
},
])
)
}
const properties = {
...existingProperties,
[body.name]: value - body.value,
}
await db.profile.updateMany({
where: {
external_id: String(body.id),
project_id: req.client.project_id,
},
data: {
properties,
},
})
}
res.status(200).json(success())
}
)
export default router

View File

@@ -0,0 +1,29 @@
import { EventPayload } from "@mixan/types";
import { db } from "../db";
export function getEvents(projectId: string) {
return db.event.findMany({
where: {
project_id: projectId,
}
})
}
export async function getProfileIdFromEvents(projectId: string, events: EventPayload[]) {
const event = events.find(item => !!item.externalId)
if(event?.externalId) {
return db.profile.findUnique({
where: {
project_id_external_id: {
project_id: projectId,
external_id: event.externalId,
}
}
}).then((res) => {
return res?.id || null
}).catch(() => {
return null
})
}
return null
}

View File

@@ -0,0 +1,7 @@
export async function hashPassword(password: string) {
return await Bun.password.hash(password);
}
export async function verifyPassword(password: string,hashedPassword: string) {
return await Bun.password.verify(password, hashedPassword);
}

View File

@@ -0,0 +1,75 @@
import { EventPayload, ProfilePayload } from "@mixan/types";
import { db } from "../db";
import { Prisma } from "@prisma/client";
export function createProfile(projectId: string, payload: ProfilePayload) {
const { id, email, first_name, last_name, avatar, properties } = payload
return db.profile.create({
data:{
external_id: id,
email,
first_name,
last_name,
avatar,
properties: properties || {},
project_id: projectId,
}
})
}
type DbProfile = Exclude<Prisma.PromiseReturnType<typeof getProfileByExternalId>, null>
export function getProfileByExternalId(projectId: string, externalId: string) {
return db.profile.findUnique({
where: {
project_id_external_id: {
project_id: projectId,
external_id: externalId,
}
}
})
}
export function getProfiles(projectId: string) {
return db.profile.findMany({
where: {
project_id: projectId,
}
})
}
export async function updateProfile(projectId: string, profileId: string, payload: Omit<ProfilePayload, 'id'>, oldProfile: DbProfile) {
const { email, first_name, last_name, avatar, properties } = payload
return db.profile.update({
where: {
project_id_external_id: {
project_id: projectId,
external_id: profileId,
}
},
data: {
email,
first_name,
last_name,
avatar,
properties: {
...(typeof oldProfile.properties === 'object' ? oldProfile.properties || {} : {}),
...(properties || {}),
},
},
})
}
export async function getInternalProfileId(profileId?: string | null) {
if(!profileId) {
return null
}
const profile = await db.profile.findFirst({
where: {
external_id: profileId,
}
})
return profile?.id || null
}

View File

@@ -0,0 +1,3 @@
export type MixanRequest<Body> = Omit<Express.Request,'body'> & {
body: Body
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
}
}

BIN
bun.lockb Executable file

Binary file not shown.

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@mixan/root",
"version": "1.0.0",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"module": "index.ts",
"type": "module",
"devDependencies": {
"bun-types": "latest",
"semver": "^7.5.4"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"tsup": "^7.2.0"
}
}

191
packages/sdk/index.ts Normal file
View File

@@ -0,0 +1,191 @@
import {
EventPayload,
MixanErrorResponse,
MixanIssuesResponse,
MixanResponse,
ProfilePayload,
} from '@mixan/types'
type MixanOptions = {
url: string
clientSecret: string
batchInterval?: number
maxBatchSize?: number
verbose?: boolean
}
class Fetcher {
private url: string
private clientSecret: string
private logger: (...args: any[]) => void
constructor(options: MixanOptions) {
this.url = options.url
this.clientSecret = options.clientSecret
this.logger = options.verbose ? console.log : () => {}
}
post(path: string, data: Record<string, any>) {
const url = `${this.url}${path}`
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2))
return fetch(url, {
headers: {
['mixan-client-secret']: this.clientSecret,
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(data),
})
.then(async (res) => {
const response = await res.json<
MixanIssuesResponse | MixanErrorResponse | MixanResponse<unknown>
>()
if ('status' in response && response.status === 'ok') {
return response
}
if ('code' in response) {
this.logger(`Mixan error: [${response.code}] ${response.message}`)
return null
}
if ('issues' in response) {
this.logger(`Mixan issues:`)
response.issues.forEach((issue) => {
this.logger(` - ${issue.message} (${issue.value})`)
})
return null
}
return null
})
.catch(() => {
return null
})
}
}
class Batcher<T extends any> {
queue: T[] = []
timer?: Timer
callback: (queue: T[]) => void
maxBatchSize = 10
batchInterval = 10000
constructor(options: MixanOptions, callback: (queue: T[]) => void) {
this.callback = callback
if (options.maxBatchSize) {
this.maxBatchSize = options.maxBatchSize
}
if (options.batchInterval) {
this.batchInterval = options.batchInterval
}
}
add(payload: T) {
this.queue.push(payload)
this.flush()
}
flush() {
if (this.timer) {
clearTimeout(this.timer)
}
if (this.queue.length === 0) {
return
}
if (this.queue.length > this.maxBatchSize) {
this.send()
return
}
this.timer = setTimeout(this.send.bind(this), this.batchInterval)
}
send() {
this.callback(this.queue)
this.queue = []
}
}
export class Mixan {
private fetch: Fetcher
private eventBatcher: Batcher<EventPayload>
private profile: ProfilePayload | null = null
constructor(options: MixanOptions) {
this.fetch = new Fetcher(options)
this.eventBatcher = new Batcher(options, (queue) => {
this.fetch.post(
'/events',
queue.map((item) => ({
...item,
externalId: item.externalId || this.profile?.id,
}))
)
})
}
timestamp() {
return new Date().toISOString()
}
event(name: string, properties: Record<string, any>) {
this.eventBatcher.add({
name,
properties,
time: this.timestamp(),
externalId: this.profile?.id || null,
})
}
async setUser(profile: ProfilePayload) {
this.profile = profile
await this.fetch.post('/profiles', profile)
}
async setUserProperty(name: string, value: any) {
await this.fetch.post('/profiles', {
...this.profile,
properties: {
[name]: value,
},
})
}
async increment(name: string, value: number = 1) {
if (!this.profile) {
return
}
await this.fetch.post('/profiles/increment', {
id: this.profile.id,
name,
value,
})
}
async decrement(name: string, value: number = 1) {
if (!this.profile) {
return
}
await this.fetch.post('/profiles/decrement', {
id: this.profile.id,
name,
value,
})
}
screenView(route: string, properties?: Record<string, any>) {
this.event('screen_view', {
...(properties || {}),
route,
})
}
}

13
packages/sdk/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@mixan/sdk",
"version": "0.0.1",
"type": "module",
"module": "index.ts",
"devDependencies": {
"@mixan/types": "workspace:*",
"bun-types": "latest"
},
"dependencies": {
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"outDir": "dist",
"allowImportingTsExtensions": false,
"noEmit": false,
"types": [
"bun-types" // add Bun global
],
}
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["index.ts"],
format: ["cjs", "esm"], // Build for commonJS and ESmodules
dts: true, // Generate declaration file (.d.ts)
splitting: false,
sourcemap: true,
clean: true,
});

15
packages/types/README.md Normal file
View File

@@ -0,0 +1,15 @@
# types
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.0.4. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

74
packages/types/index.ts Normal file
View File

@@ -0,0 +1,74 @@
export type MixanJson = Record<string, any>
export type EventPayload = {
name: string
time: string
externalId: string | null
properties: MixanJson
}
export type ProfilePayload = {
first_name?: string
last_name?: string
email?: string
avatar?: string
id: string
properties: MixanJson
}
export type ProfileIncrementPayload = {
name: string
value: number
id: string
}
export type ProfileDecrementPayload = {
name: string
value: number
id: string
}
// Batching
export type BatchEvent = {
type: 'event',
payload: EventPayload
}
export type BatchProfile = {
type: 'profile',
payload: ProfilePayload
}
export type BatchProfileIncrement = {
type: 'profile_increment',
payload: ProfileIncrementPayload
}
export type BatchProfileDecrement = {
type: 'profile_decrement',
payload: ProfileDecrementPayload
}
export type BatchItem = BatchEvent | BatchProfile | BatchProfileIncrement | BatchProfileDecrement
export type BatchPayload = Array<BatchItem>
export type MixanIssue = {
field: string
message: string
value: any
}
export type MixanIssuesResponse = {
issues: Array<MixanIssue>,
}
export type MixanErrorResponse = {
code: string
message: string
}
export type MixanResponse<T> = {
result: T
status: 'ok'
}

View File

@@ -0,0 +1,12 @@
{
"name": "@mixan/types",
"version": "0.0.1",
"type": "module",
"module": "index.ts",
"devDependencies": {
"bun-types": "latest"
},
"dependencies": {
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
}
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["index.ts"],
format: ["cjs", "esm"], // Build for commonJS and ESmodules
dts: true, // Generate declaration file (.d.ts)
splitting: false,
sourcemap: false,
clean: true,
});

64
publish.ts Normal file
View File

@@ -0,0 +1,64 @@
import sdkPkg from './packages/sdk/package.json'
import typesPkg from './packages/types/package.json'
import fs from 'node:fs'
import {execSync} from 'node:child_process'
import semver from 'semver'
function savePackageJson(path: string, data: Record<string, any>) {
fs.writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8')
}
function main() {
const [version] = process.argv.slice(2)
if(!version) {
return console.error('Missing version')
}
if(!semver.valid(version)) {
return console.error('Version is not valid')
}
const properties = {
private: false,
version,
type: 'module',
main: './dist/index.js',
module: './dist/index.mjs',
types: './dist/index.d.ts',
files: ['dist'],
}
savePackageJson('./packages/sdk/package.json', {
...sdkPkg,
...properties,
dependencies: Object.entries(sdkPkg.dependencies).reduce(
(acc, [depName, depVersion]) => ({
...acc,
[depName]: depName.startsWith('@mixan') ? version : depVersion,
}),
{}
),
})
savePackageJson('./packages/types/package.json', {
...typesPkg,
...properties,
})
execSync('bunx tsup', {
cwd: './packages/sdk',
})
execSync('npm publish --access=public', {
cwd: './packages/sdk',
})
execSync('bunx tsup', {
cwd: './packages/types',
})
execSync('npm publish --access=public', {
cwd: './packages/types',
})
}
main()