chore(deps): bump dependencies

This commit is contained in:
Neko Ayaka
2026-08-26 19:01:04 +08:00
parent 4803f1c16f
commit 6a24582696
42 changed files with 10997 additions and 12681 deletions
@@ -9,6 +9,7 @@ import type {
StageViewSnapshotPayload,
} from '@proj-airi/stage-shared/godot-stage'
import type { BrowserWindow } from 'electron'
import type { WebSocketMessage, WebSocketPeer } from 'h3'
import type { InferOutput } from 'valibot'
import type {
@@ -54,29 +55,15 @@ import {
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
import { getElectronMainDirname } from '../../../libs/electron/location'
type MainContext = ReturnType<typeof createContext>['context']
type GodotStageWebSocketHooks = Exclude<Parameters<typeof defineWebSocketHandler>[0], (...args: never[]) => unknown>
type GodotStagePeer = Parameters<NonNullable<GodotStageWebSocketHooks['open']>>[0]
type GodotStageMessage = Parameters<NonNullable<GodotStageWebSocketHooks['message']>>[1]
type GodotStageProcess = ChildProcessByStdio<null, Readable, Readable>
type MainContext = ReturnType<typeof createContext>['context']
const DEFAULT_GODOT_REMOTE_DEBUG_URI = 'tcp://127.0.0.1:6007'
interface Deferred<T> {
promise: Promise<T>
reject: (error?: unknown) => void
resolve: (value: T | PromiseLike<T>) => void
}
interface ListenerChannel<T> {
publish: (payload: T) => void
subscribe: (callback: (payload: T) => void) => () => void
}
interface GodotStageSocketRuntime {
port: number
server: ReturnType<typeof serve>
token: string
resolve: (value: PromiseLike<T> | T) => void
}
interface GodotStageSceneApplyPayload {
@@ -86,12 +73,23 @@ interface GodotStageSceneApplyPayload {
path: string
}
interface GodotStageSocketRuntime {
port: number
server: ReturnType<typeof serve>
token: string
}
interface ListenerChannel<T> {
publish: (payload: T) => void
subscribe: (callback: (payload: T) => void) => () => void
}
const godotStageSceneInputPayloadSchema = object({
modelId: string(),
format: literal('vrm'),
name: string(),
fileName: string(),
data: instance(Uint8Array),
fileName: string(),
format: literal('vrm'),
modelId: string(),
name: string(),
})
const godotStageSocketEnvelopeSchema = object({
@@ -103,8 +101,6 @@ const godotStagePayloadMessageSchema = object({
message: string(),
})
type GodotStageSocketEnvelope = InferOutput<typeof godotStageSocketEnvelopeSchema>
/**
* Godot sidecar lifecycle controller owned by Electron main.
*
@@ -124,7 +120,7 @@ export interface GodotStageManager {
applySceneInput: (payload: ElectronGodotStageSceneInputPayload) => Promise<void>
applyViewPatch: (payload: StageViewPatch) => Promise<StageViewRequestAckPayload>
getStatus: () => ElectronGodotStageStatus
getViewSnapshot: () => StageViewSnapshotPayload | null
getViewSnapshot: () => null | StageViewSnapshotPayload
requestViewSnapshot: () => Promise<StageViewRequestAckPayload>
start: () => Promise<ElectronGodotStageStatus>
stop: () => Promise<ElectronGodotStageStatus>
@@ -133,242 +129,12 @@ export interface GodotStageManager {
subscribeViewSnapshot: (callback: (snapshot: StageViewSnapshotPayload) => void) => () => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: Deferred<T>['resolve']
let reject!: Deferred<T>['reject']
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return {
promise,
reject,
resolve,
}
}
function createListenerChannel<T>(onListenerError: (error: unknown) => void): ListenerChannel<T> {
const listeners = new Set<(payload: T) => void>()
return {
publish(payload) {
for (const listener of listeners) {
try {
listener(payload)
}
catch (error) {
onListenerError(error)
}
}
},
subscribe(callback) {
listeners.add(callback)
return () => {
listeners.delete(callback)
}
},
}
}
function createInitialStatus(): ElectronGodotStageStatus {
return {
state: 'stopped',
pid: null,
updatedAt: Date.now(),
}
}
function createSocketEnvelope(type: string, payload?: unknown) {
return JSON.stringify({ type, payload })
}
function waitForProcessExit(exitPromise: Promise<void>, timeoutMs: number) {
return Promise.race([
exitPromise.then(() => true, () => false),
new Promise<boolean>(resolve => setTimeout(resolve, timeoutMs, false)),
])
}
function pipeProcessLog(stream: Readable, write: (message: string) => void) {
stream.on('data', (data) => {
const message = data.toString('utf-8').trim()
if (message) {
write(message)
}
})
}
function normalizeFileName(fileName: string) {
const normalized = basename(fileName.trim())
return normalized || 'model.bin'
}
function parseSocketMessage(message: GodotStageMessage): GodotStageSocketEnvelope {
const parsed = safeDestr<unknown>(message.text(), { strict: true })
const result = safeParse(godotStageSocketEnvelopeSchema, parsed)
if (!result.success)
throw new Error('Invalid Godot stage WebSocket envelope.')
return result.output
}
function getPayloadMessage(payload: unknown) {
const result = safeParse(godotStagePayloadMessageSchema, payload)
if (!result.success) {
return undefined
}
return result.output.message
}
function parseSceneInputPayload(payload: unknown): ElectronGodotStageSceneInputPayload {
const result = safeParse(godotStageSceneInputPayloadSchema, payload)
if (!result.success)
throw new Error('Invalid Godot stage scene input payload.')
return result.output
}
function resolveGodotStageStorageRoot() {
return join(app.getPath('userData'), 'godot-stage')
}
function resolveGodotStageDebugLaunchOptions() {
const remoteDebugEnabled = ['1', 'true', 'yes', 'on'].includes(
(process.env.GODOT_STAGE_REMOTE_DEBUG ?? '').trim().toLowerCase(),
)
const remoteDebugUri = remoteDebugEnabled
? process.env.GODOT_STAGE_REMOTE_DEBUG_URI?.trim() || DEFAULT_GODOT_REMOTE_DEBUG_URI
: undefined
// Godot engine/debugger flags must stay before `--`; StageRoot arguments stay
// after it and are assembled next to the WebSocket URL.
return {
engineArgs: remoteDebugUri ? ['--remote-debug', remoteDebugUri] : [],
remoteDebugUri,
}
}
function resolveGodotStageProcessEnv(): NodeJS.ProcessEnv {
return {
...process.env,
AIRI_GODOT_STAGE_DEV_MODE: app.isPackaged
? process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '0'
: process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '1',
}
}
interface GodotBinaryResolution {
executable: string
mode: 'engine' | 'exported'
}
// Dev builds run the Godot engine against the workspace project.godot.
async function resolveGodotProjectPath() {
let currentDirectory = getElectronMainDirname()
while (true) {
const projectPath = resolve(currentDirectory, 'engines', 'stage-tamagotchi-godot')
try {
await access(join(projectPath, 'project.godot'))
return projectPath
}
catch {}
const parentDirectory = dirname(currentDirectory)
if (parentDirectory === currentDirectory) {
break
}
currentDirectory = parentDirectory
}
throw new Error(`Unable to locate engines/stage-tamagotchi-godot/project.godot from ${getElectronMainDirname()}.`)
}
// Packaged builds ship a pre-exported sidecar under Electron resources.
async function resolveExportedGodotBinary(): Promise<string | undefined> {
const platform = process.platform
let binaryName: string
if (platform === 'win32') {
binaryName = 'godot-stage.exe'
}
else if (platform === 'darwin') {
binaryName = join('godot-stage.app', 'Contents', 'MacOS', 'godot-stage')
}
else {
binaryName = 'godot-stage'
}
const binaryPath = join(process.resourcesPath, 'godot-stage', binaryName)
try {
await access(binaryPath)
return binaryPath
}
catch {
return undefined
}
}
async function validateConfiguredGodotEnginePath(executable: string) {
let executableStats
try {
executableStats = await stat(executable)
}
catch (error) {
throw new Error(
'GODOT4 points to a missing Godot executable.\n'
+ `Configured path: ${executable}\n`
+ 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable before starting dev mode.\n'
+ `Original error: ${errorMessageFrom(error) ?? 'unknown error'}`,
)
}
if (!executableStats.isFile()) {
throw new Error(
'GODOT4 must point to the Godot executable file, not a directory or app bundle.\n'
+ `Configured path: ${executable}\n`
+ 'Examples:\n'
+ ' Windows: C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe\n'
+ ' macOS: /Applications/Godot_mono.app/Contents/MacOS/Godot\n'
+ ' Linux: /path/to/Godot_v4.x-stable_mono_linux.x86_64',
)
}
}
async function resolveGodotBinary(): Promise<GodotBinaryResolution> {
if (app.isPackaged) {
const exported = await resolveExportedGodotBinary()
if (exported) {
return { executable: exported, mode: 'exported' }
}
throw new Error(
'Godot stage exported binary not found. '
+ `Expected at: ${join(process.resourcesPath, 'godot-stage')}`,
)
}
const envPath = process.env.GODOT4?.trim()
if (!envPath) {
throw new Error(
'GODOT4 is required to start Godot Stage in development mode.\n'
+ 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable, then restart the Electron dev app.\n'
+ 'Examples:\n'
+ ' PowerShell: $env:GODOT4 = "C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe"\n'
+ ' Bash: export GODOT4="/path/to/godot"',
)
}
await validateConfiguredGodotEnginePath(envPath)
return { executable: envPath, mode: 'engine' }
}
type GodotStageSocketEnvelope = InferOutput<typeof godotStageSocketEnvelopeSchema>
/**
* Creates the shared Godot stage manager.
@@ -397,8 +163,8 @@ export function createGodotStageManager(): GodotStageManager {
let currentProcessExit = createDeferred<void>()
let currentReady: Deferred<void> | undefined
let currentSocketRuntime: GodotStageSocketRuntime | undefined
let currentSocketPeer: GodotStagePeer | undefined
let currentViewSnapshot: StageViewSnapshotPayload | null = null
let currentSocketPeer: undefined | WebSocketPeer
let currentViewSnapshot: null | StageViewSnapshotPayload = null
let expectedProcessExit = false
function setStatus(next: Partial<ElectronGodotStageStatus> & Pick<ElectronGodotStageStatus, 'state'>) {
@@ -492,35 +258,12 @@ export function createGodotStageManager(): GodotStageManager {
function handleSocketMessage(message: GodotStageSocketEnvelope) {
switch (message.type) {
case 'stage.ready': {
setStatus({
state: 'running',
pid: currentProcess?.pid ?? null,
lastError: undefined,
})
currentReady?.resolve()
currentReady = undefined
return
}
case 'stage.fatal': {
const error = getPayloadMessage(message.payload) ?? 'Godot stage reported a fatal startup error.'
setStatus({
state: 'error',
pid: currentProcess?.pid ?? null,
lastError: error,
})
currentReady?.reject(new Error(error))
currentReady = undefined
currentProcess?.kill()
return
}
case 'scene.applied': {
if (currentStatus.state === 'running' && currentStatus.lastError) {
setStatus({
state: 'running',
pid: currentProcess?.pid ?? null,
lastError: undefined,
pid: currentProcess?.pid ?? null,
state: 'running',
})
}
return
@@ -528,24 +271,47 @@ export function createGodotStageManager(): GodotStageManager {
case 'scene.error': {
const error = getPayloadMessage(message.payload) ?? 'Godot stage failed to apply scene input.'
setStatus({
state: currentStatus.state,
pid: currentProcess?.pid ?? null,
lastError: error,
pid: currentProcess?.pid ?? null,
state: currentStatus.state,
})
return
}
case 'stage.view.snapshot': {
case 'stage.fatal': {
const error = getPayloadMessage(message.payload) ?? 'Godot stage reported a fatal startup error.'
setStatus({
lastError: error,
pid: currentProcess?.pid ?? null,
state: 'error',
})
currentReady?.reject(new Error(error))
currentReady = undefined
currentProcess?.kill()
return
}
case 'stage.ready': {
setStatus({
lastError: undefined,
pid: currentProcess?.pid ?? null,
state: 'running',
})
currentReady?.resolve()
currentReady = undefined
return
}
case 'stage.view.error': {
try {
broadcastViewSnapshot(parseStageViewSnapshotPayload(message.payload))
broadcastViewError(parseStageViewErrorPayload(message.payload))
}
catch (error) {
broadcastInvalidViewPayloadError(error)
}
return
}
case 'stage.view.error': {
case 'stage.view.snapshot': {
try {
broadcastViewError(parseStageViewErrorPayload(message.payload))
broadcastViewSnapshot(parseStageViewSnapshotPayload(message.payload))
}
catch (error) {
broadcastInvalidViewPayloadError(error)
@@ -569,6 +335,19 @@ export function createGodotStageManager(): GodotStageManager {
const appServer = new H3()
appServer.get('/ws', defineWebSocketHandler({
close: (peer) => {
if (currentSocketPeer?.id === peer.id) {
currentSocketPeer = undefined
}
},
message: (_peer, message) => {
try {
handleSocketMessage(parseSocketMessage(message))
}
catch (error) {
log.withError(error).warn('failed to parse Godot websocket message')
}
},
open: (peer) => {
const requestUrl = peer.request.url ?? ''
const url = new URL(requestUrl, `ws://${host}:${port}`)
@@ -580,33 +359,20 @@ export function createGodotStageManager(): GodotStageManager {
currentSocketPeer = peer
log.withFields({ peer: peer.id }).debug('Godot websocket connected')
},
message: (_peer, message) => {
try {
handleSocketMessage(parseSocketMessage(message))
}
catch (error) {
log.withError(error).warn('failed to parse Godot websocket message')
}
},
close: (peer) => {
if (currentSocketPeer?.id === peer.id) {
currentSocketPeer = undefined
}
},
}))
const server = serve(appServer, {
// @ts-expect-error - h3 does not extend the crossws response type.
plugins: [ws({ resolve: async req => (await appServer.fetch(req)).crossws })],
port,
hostname: host,
manual: true,
reusePort: false,
silent: true,
gracefulShutdown: {
forceTimeout: 0.25,
gracefulTimeout: 0.25,
},
hostname: host,
manual: true,
// @ts-expect-error - h3 does not extend the crossws response type.
plugins: [ws({ resolve: async req => (await appServer.fetch(req)).crossws })],
port,
reusePort: false,
silent: true,
})
await server.serve()
@@ -632,9 +398,9 @@ export function createGodotStageManager(): GodotStageManager {
const message = errorMessageFrom(error) ?? 'Failed to spawn Godot stage process.'
setStatus({
state: 'error',
pid: processHandle.pid ?? null,
lastError: message,
pid: processHandle.pid ?? null,
state: 'error',
})
currentReady?.reject(error)
currentReady = undefined
@@ -659,16 +425,16 @@ export function createGodotStageManager(): GodotStageManager {
if (expectedProcessExit) {
setStatus({
state: 'stopped',
pid: null,
lastError: undefined,
pid: null,
state: 'stopped',
})
}
else {
setStatus({
state: 'error',
pid: null,
lastError: exitMessage,
pid: null,
state: 'error',
})
}
@@ -679,10 +445,34 @@ export function createGodotStageManager(): GodotStageManager {
}
return {
subscribe(callback) {
const unsubscribe = statusListeners.subscribe(callback)
callback(currentStatus)
return unsubscribe
async applySceneInput(payload) {
await lifecycleMutex.runExclusive(async () => {
if (currentStatus.state !== 'running') {
throw new Error('Godot stage is not running.')
}
const sceneInputPayload = parseSceneInputPayload(payload)
const fileName = normalizeFileName(sceneInputPayload.fileName)
const modelDirectory = join(resolveGodotStageStorageRoot(), 'models', sceneInputPayload.modelId)
const materializedPath = join(modelDirectory, fileName)
await mkdir(modelDirectory, { recursive: true })
await writeFile(materializedPath, sceneInputPayload.data)
sendSceneInputToGodot({
format: sceneInputPayload.format,
modelId: sceneInputPayload.modelId,
name: sceneInputPayload.name,
path: materializedPath,
})
})
},
async applyViewPatch(payload) {
return await lifecycleMutex.runExclusive(async () => {
const patch = parseStageViewPatchPayload(payload)
return sendViewRequest('host.view.patch', { patch })
})
},
getStatus() {
return currentStatus
@@ -690,11 +480,10 @@ export function createGodotStageManager(): GodotStageManager {
getViewSnapshot() {
return currentViewSnapshot
},
subscribeViewSnapshot(callback) {
return viewSnapshotListeners.subscribe(callback)
},
subscribeViewError(callback) {
return viewErrorListeners.subscribe(callback)
async requestViewSnapshot() {
return await lifecycleMutex.runExclusive(async () => {
return sendViewRequest('host.view.request_snapshot')
})
},
async start() {
return await lifecycleMutex.runExclusive(async () => {
@@ -732,9 +521,9 @@ export function createGodotStageManager(): GodotStageManager {
currentReady = readyDeferred
expectedProcessExit = false
setStatus({
state: 'starting',
pid: null,
lastError: undefined,
pid: null,
state: 'starting',
})
let spawnArgs: string[]
@@ -778,9 +567,9 @@ export function createGodotStageManager(): GodotStageManager {
attachProcessListeners(processHandle)
setStatus({
state: 'starting',
pid: processHandle.pid ?? null,
lastError: undefined,
pid: processHandle.pid ?? null,
state: 'starting',
})
try {
@@ -800,9 +589,9 @@ export function createGodotStageManager(): GodotStageManager {
}
await stopSocketRuntime()
setStatus({
state: 'error',
pid: null,
lastError: errorMessageFrom(error) ?? 'Failed to start Godot stage.',
pid: null,
state: 'error',
})
throw error
}
@@ -813,9 +602,9 @@ export function createGodotStageManager(): GodotStageManager {
if (!currentProcess) {
await stopSocketRuntime()
setStatus({
state: 'stopped',
pid: null,
lastError: undefined,
pid: null,
state: 'stopped',
})
return currentStatus
}
@@ -825,9 +614,9 @@ export function createGodotStageManager(): GodotStageManager {
expectedProcessExit = true
setStatus({
state: 'stopping',
pid: activeProcess.pid ?? null,
lastError: undefined,
pid: activeProcess.pid ?? null,
state: 'stopping',
})
try {
@@ -842,9 +631,9 @@ export function createGodotStageManager(): GodotStageManager {
}
catch (error) {
setStatus({
state: 'error',
pid: activeProcess.pid ?? null,
lastError: errorMessageFrom(error) ?? 'Failed to stop Godot stage.',
pid: activeProcess.pid ?? null,
state: 'error',
})
throw error
}
@@ -853,73 +642,28 @@ export function createGodotStageManager(): GodotStageManager {
}
setStatus({
state: 'stopped',
pid: null,
lastError: undefined,
pid: null,
state: 'stopped',
})
return currentStatus
})
},
async applySceneInput(payload) {
await lifecycleMutex.runExclusive(async () => {
if (currentStatus.state !== 'running') {
throw new Error('Godot stage is not running.')
}
const sceneInputPayload = parseSceneInputPayload(payload)
const fileName = normalizeFileName(sceneInputPayload.fileName)
const modelDirectory = join(resolveGodotStageStorageRoot(), 'models', sceneInputPayload.modelId)
const materializedPath = join(modelDirectory, fileName)
await mkdir(modelDirectory, { recursive: true })
await writeFile(materializedPath, sceneInputPayload.data)
sendSceneInputToGodot({
modelId: sceneInputPayload.modelId,
format: sceneInputPayload.format,
name: sceneInputPayload.name,
path: materializedPath,
})
})
subscribe(callback) {
const unsubscribe = statusListeners.subscribe(callback)
callback(currentStatus)
return unsubscribe
},
async applyViewPatch(payload) {
return await lifecycleMutex.runExclusive(async () => {
const patch = parseStageViewPatchPayload(payload)
return sendViewRequest('host.view.patch', { patch })
})
subscribeViewError(callback) {
return viewErrorListeners.subscribe(callback)
},
async requestViewSnapshot() {
return await lifecycleMutex.runExclusive(async () => {
return sendViewRequest('host.view.request_snapshot')
})
subscribeViewSnapshot(callback) {
return viewSnapshotListeners.subscribe(callback)
},
}
}
/**
* Creates and wires the shared Godot stage manager into app lifecycle hooks.
*
* Use when:
* - Electron main needs one app-wide Godot sidecar lifecycle owner
*
* Expects:
* - App shutdown to call the registered `onAppBeforeQuit` hook
*
* Returns:
* - The ready-to-use Godot stage manager
*/
export function setupGodotStageManager() {
const manager = createGodotStageManager()
onAppBeforeQuit(async () => {
await manager.stop()
})
return manager
}
/**
* Registers Godot stage invoke handlers for one Electron window context.
*
@@ -972,3 +716,257 @@ export function createGodotStageService(params: {
params.window.on('closed', cleanup)
return cleanup
}
/**
* Creates and wires the shared Godot stage manager into app lifecycle hooks.
*
* Use when:
* - Electron main needs one app-wide Godot sidecar lifecycle owner
*
* Expects:
* - App shutdown to call the registered `onAppBeforeQuit` hook
*
* Returns:
* - The ready-to-use Godot stage manager
*/
export function setupGodotStageManager() {
const manager = createGodotStageManager()
onAppBeforeQuit(async () => {
await manager.stop()
})
return manager
}
function createDeferred<T>(): Deferred<T> {
let resolve!: Deferred<T>['resolve']
let reject!: Deferred<T>['reject']
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return {
promise,
reject,
resolve,
}
}
function createInitialStatus(): ElectronGodotStageStatus {
return {
pid: null,
state: 'stopped',
updatedAt: Date.now(),
}
}
function createListenerChannel<T>(onListenerError: (error: unknown) => void): ListenerChannel<T> {
const listeners = new Set<(payload: T) => void>()
return {
publish(payload) {
for (const listener of listeners) {
try {
listener(payload)
}
catch (error) {
onListenerError(error)
}
}
},
subscribe(callback) {
listeners.add(callback)
return () => {
listeners.delete(callback)
}
},
}
}
function createSocketEnvelope(type: string, payload?: unknown) {
return JSON.stringify({ payload, type })
}
function getPayloadMessage(payload: unknown) {
const result = safeParse(godotStagePayloadMessageSchema, payload)
if (!result.success) {
return undefined
}
return result.output.message
}
function normalizeFileName(fileName: string) {
const normalized = basename(fileName.trim())
return normalized || 'model.bin'
}
function parseSceneInputPayload(payload: unknown): ElectronGodotStageSceneInputPayload {
const result = safeParse(godotStageSceneInputPayloadSchema, payload)
if (!result.success)
throw new Error('Invalid Godot stage scene input payload.')
return result.output
}
function parseSocketMessage(message: WebSocketMessage): GodotStageSocketEnvelope {
const parsed = safeDestr<unknown>(message.text(), { strict: true })
const result = safeParse(godotStageSocketEnvelopeSchema, parsed)
if (!result.success)
throw new Error('Invalid Godot stage WebSocket envelope.')
return result.output
}
function pipeProcessLog(stream: Readable, write: (message: string) => void) {
stream.on('data', (data) => {
const message = data.toString('utf-8').trim()
if (message) {
write(message)
}
})
}
// Packaged builds ship a pre-exported sidecar under Electron resources.
async function resolveExportedGodotBinary(): Promise<string | undefined> {
const platform = process.platform
let binaryName: string
if (platform === 'win32') {
binaryName = 'godot-stage.exe'
}
else if (platform === 'darwin') {
binaryName = join('godot-stage.app', 'Contents', 'MacOS', 'godot-stage')
}
else {
binaryName = 'godot-stage'
}
const binaryPath = join(process.resourcesPath, 'godot-stage', binaryName)
try {
await access(binaryPath)
return binaryPath
}
catch {
return undefined
}
}
async function resolveGodotBinary(): Promise<GodotBinaryResolution> {
if (app.isPackaged) {
const exported = await resolveExportedGodotBinary()
if (exported) {
return { executable: exported, mode: 'exported' }
}
throw new Error(
'Godot stage exported binary not found. '
+ `Expected at: ${join(process.resourcesPath, 'godot-stage')}`,
)
}
const envPath = process.env.GODOT4?.trim()
if (!envPath) {
throw new Error(
'GODOT4 is required to start Godot Stage in development mode.\n'
+ 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable, then restart the Electron dev app.\n'
+ 'Examples:\n'
+ ' PowerShell: $env:GODOT4 = "C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe"\n'
+ ' Bash: export GODOT4="/path/to/godot"',
)
}
await validateConfiguredGodotEnginePath(envPath)
return { executable: envPath, mode: 'engine' }
}
// Dev builds run the Godot engine against the workspace project.godot.
async function resolveGodotProjectPath() {
let currentDirectory = getElectronMainDirname()
while (true) {
const projectPath = resolve(currentDirectory, 'engines', 'stage-tamagotchi-godot')
try {
await access(join(projectPath, 'project.godot'))
return projectPath
}
catch {}
const parentDirectory = dirname(currentDirectory)
if (parentDirectory === currentDirectory) {
break
}
currentDirectory = parentDirectory
}
throw new Error(`Unable to locate engines/stage-tamagotchi-godot/project.godot from ${getElectronMainDirname()}.`)
}
function resolveGodotStageDebugLaunchOptions() {
const remoteDebugEnabled = ['1', 'on', 'true', 'yes'].includes(
(process.env.GODOT_STAGE_REMOTE_DEBUG ?? '').trim().toLowerCase(),
)
const remoteDebugUri = remoteDebugEnabled
? process.env.GODOT_STAGE_REMOTE_DEBUG_URI?.trim() || DEFAULT_GODOT_REMOTE_DEBUG_URI
: undefined
// Godot engine/debugger flags must stay before `--`; StageRoot arguments stay
// after it and are assembled next to the WebSocket URL.
return {
engineArgs: remoteDebugUri ? ['--remote-debug', remoteDebugUri] : [],
remoteDebugUri,
}
}
function resolveGodotStageProcessEnv(): NodeJS.ProcessEnv {
return {
...process.env,
AIRI_GODOT_STAGE_DEV_MODE: app.isPackaged
? process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '0'
: process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '1',
}
}
function resolveGodotStageStorageRoot() {
return join(app.getPath('userData'), 'godot-stage')
}
async function validateConfiguredGodotEnginePath(executable: string) {
let executableStats
try {
executableStats = await stat(executable)
}
catch (error) {
throw new Error(
'GODOT4 points to a missing Godot executable.\n'
+ `Configured path: ${executable}\n`
+ 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable before starting dev mode.\n'
+ `Original error: ${errorMessageFrom(error) ?? 'unknown error'}`,
)
}
if (!executableStats.isFile()) {
throw new Error(
'GODOT4 must point to the Godot executable file, not a directory or app bundle.\n'
+ `Configured path: ${executable}\n`
+ 'Examples:\n'
+ ' Windows: C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe\n'
+ ' macOS: /Applications/Godot_mono.app/Contents/MacOS/Godot\n'
+ ' Linux: /path/to/Godot_v4.x-stable_mono_linux.x86_64',
)
}
}
function waitForProcessExit(exitPromise: Promise<void>, timeoutMs: number) {
return Promise.race([
exitPromise.then(() => true, () => false),
new Promise<boolean>(resolve => setTimeout(resolve, timeoutMs, false)),
])
}
@@ -5,7 +5,7 @@ import posthog from 'posthog-js'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_PROJECT_KEY,
} from '../../../../../posthog.config'
} from '@proj-airi/stage-shared/analytics/posthog'
/** Creates the auth analytics adapter and initializes its provider SDK. */
export function createPosthogAdapter(): AnalyticsAdapter {
+1 -1
View File
@@ -1,6 +1,6 @@
import posthog from 'posthog-js'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../../../posthog.config'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '@proj-airi/stage-shared/analytics/posthog'
if (!import.meta.env.DEV) {
posthog.init(POSTHOG_PROJECT_KEY, {
+1
View File
@@ -20,6 +20,7 @@
"@moeru/std": "catalog:",
"@proj-airi/chromatic": "catalog:",
"@proj-airi/i18n": "workspace:^",
"@proj-airi/stage-shared": "workspace:^",
"@vueuse/core": "catalog:",
"colorjs.io": "catalog:",
"date-fns": "catalog:",
+274 -267
View File
@@ -1,280 +1,16 @@
import type { Preset } from 'unocss'
import { presetChromatic } from '@proj-airi/unocss-preset-chromatic'
import { blackA, cyan, grass, green, indigo, mauve, purple, red, slate, teal, violet } from '@radix-ui/colors'
import { defineConfig, presetAttributify, presetIcons, presetTypography, presetWebFonts, presetWind3, transformerDirectives, transformerVariantGroup } from 'unocss'
export default defineConfig({
presets: [
presetAttributify(),
presetTypography({
cssExtend: {
'h1': {
'margin-bottom': '1rem',
},
'a': {
'color': '#223f5dff',
'text-decoration': 'underline',
'text-decoration-style': 'dotted',
'text-decoration-color': '#9fa4b1ff',
'transition': 'color 0.2s ease-in-out',
},
'a:hover': {
'--primary': '207 62% 59%',
'color': 'hsl(var(--primary))',
},
'.dark a': {
'color': '#9ca0a4',
'text-decoration-color': '#4b5056',
},
'code::before': {
content: 'normal',
},
'code::after': {
content: 'normal',
},
'pre': {
'margin-top': '0.5rem',
'margin-bottom': '0',
},
'p': {
'margin-top': '0.5rem',
'margin-bottom': '0.5rem',
},
'details': {
'margin-top': '0.5rem',
'margin-bottom': '0.5rem',
'padding': '0.5rem 1rem',
'background-color': '#a6ceef1a',
},
'.dark details': {
'background-color': '#191c1e',
},
'ol': {
'margin-top': '0.25rem',
'margin-bottom': '0.25rem',
'padding-inline-start': '1.25rem',
},
'ol li': {
'padding-inline-start': '0.25rem',
},
'ul': {
'margin-top': '0.25rem',
'margin-bottom': '0.25rem',
'padding-inline-start': '1.25rem',
},
'ul li': {
'padding-inline-start': '0.25rem',
},
'li': {
'margin-top': '0',
'margin-bottom': '0',
},
'li p': {
'margin-top': '0.25rem',
'margin-bottom': '0.25rem',
},
'li blockquote': {
'margin-top': '1rem',
'margin-bottom': '1rem',
},
},
}),
presetWind3(),
presetWebFonts({
fonts: {
'sans': {
name: 'DM Sans Variable',
provider: 'none',
},
'serif': {
name: 'DM Serif Display',
provider: 'none',
},
'mono': {
name: 'DM Mono',
provider: 'none',
},
'sans-rounded': {
name: 'Comfortaa Variable',
provider: 'none',
},
'mystery-quest': {
name: 'Mystery Quest',
},
'grandstander': {
name: 'Grandstander',
},
},
}),
presetIcons(),
presetChromatic({
baseHue: 220.44,
colors: {
primary: 0,
complementary: 180,
},
}),
],
export default defineConfig<object>({
content: {
filesystem: [
'.vitepress/**/*.{js,ts,vue}',
'content/**/*.md',
],
},
safelist: [
'-ml-8',
'top-0',
'hidden',
'border-0',
'opacity-0',
'group-hover:opacity-100',
'focus:opacity-100',
'lg:flex',
'transition-opacity',
'duration-200',
'ease-in-out',
'[&_span]:focus:opacity-100',
'[&_span_>_span]:focus:outline',
],
theme: {
fontFamily: {
'sans': `ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-rounded': `"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-serif-halloween-secondary': `"Grandstander", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-serif-halloween': `"Mystery Quest", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
code: 'hsl(var(--code))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
...blackA,
...mauve,
...violet,
...green,
...red,
...grass,
...teal,
...cyan,
...indigo,
...purple,
...slate,
},
/**
* https://github.com/unocss/unocss/blob/1031312057a3bea1082b7d938eb2ad640f57613a/packages-presets/preset-wind4/src/theme/animate.ts
* https://unocss.dev/presets/wind4#transformdirectives
*/
animation: {
keyframes: {
overlayShow: '{from{opacity:0}to{opacity:1}}',
contentShow: '{from{opacity:0;transform:translate(-50%, -48%) scale(0.96)}to{opacity:1;transform:translate(-50%, -50%) scale(1)}}',
slideDownAndFade: '{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}',
slideLeftAndFade: '{from{opacity:0;transform:translateX(2px)}to{opacity:1;transform:translateX(0)}}',
slideUpAndFade: '{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}',
slideRightAndFade: '{from{opacity:0;transform:translateX(-2px)}to{opacity:1;transform:translateX(0)}}',
slideDown: '{from{height:0}to{height:var(--reka-collapsible-content-height)}}',
slideUp: '{from{height:var(--reka-collapsible-content-height)}to{height:0}}',
enterFromRight: '{from{opacity:0;transform:translateX(200px)}to{opacity:1;transform:translateX(0)}}',
enterFromLeft: '{from{opacity:0;transform:translateX(-200px)}to{opacity:1;transform:translateX(0)}}',
exitToRight: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(200px)}}',
exitToLeft: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(-200px)}}',
scaleIn: '{from{opacity:0;transform:rotateX(-10deg) scale(0.9)}to{opacity:1;transform:rotateX(0deg) scale(1)}}',
scaleOut: '{from{opacity:1;transform:rotateX(0deg) scale(1)}to{opacity:0;transform:rotateX(-10deg) scale(0.95)}}',
fadeIn: '{from{opacity:0}to{opacity:1}}',
fadeOut: '{from{opacity:1}to{opacity:0}}',
hide: '{from{opacity:1}to{opacity:0}}',
slideIn: '{from{transform:translateX(calc(100% + var(--viewport-padding)))}to{transform:translateX(0)}}',
swipeOut: '{from{transform:translateX(var(--reka-toast-swipe-end-x))}to{transform:translateX(calc(100% + var(--viewport-padding)))}}',
text: '{0%,100%{background-size:200% 200%;background-position:left center}50%{background-size:200% 200%;background-position:right center}}',
progress: '{0%{background-position:0 0}100%{background-position:30px 30px}}',
},
durations: {
overlayShow: '150ms',
contentShow: '150ms',
slideDownAndFade: '400ms',
slideLeftAndFade: '400ms',
slideUpAndFade: '400ms',
slideRightAndFade: '400ms',
slideDown: '300ms',
slideUp: '300ms',
scaleIn: '200ms',
scaleOut: '200ms',
fadeIn: '200ms',
fadeOut: '200ms',
enterFromLeft: '250ms',
enterFromRight: '250ms',
exitToLeft: '250ms',
exitToRight: '250ms',
hide: '100ms',
slideIn: '150ms',
swipeOut: '100ms',
text: '5s',
progress: '1s',
},
timingFns: {
overlayShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
contentShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideDownAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideLeftAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideUpAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideRightAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideDown: 'cubic-bezier(0.87, 0, 0.13, 1)',
slideUp: 'cubic-bezier(0.87, 0, 0.13, 1)',
scaleIn: 'ease',
scaleOut: 'ease',
fadeIn: 'ease',
fadeOut: 'ease',
enterFromLeft: 'ease',
enterFromRight: 'ease',
exitToLeft: 'ease',
exitToRight: 'ease',
hide: 'ease-in',
slideIn: 'cubic-bezier(0.16, 1, 0.3, 1)',
swipeOut: 'ease-out',
text: 'ease',
progress: 'linear',
},
counts: {
text: 'infinite',
progress: 'infinite',
},
},
},
shortcuts: {
'bg-gradient-radial': 'bg-gradient-radial-[var(--tw-gradient-stops)]',
},
rules: [
[/^bg-gradient-radial-\[(.+)\]$/, ([, d]) => ({ 'background-image': `radial-gradient(${d})` })],
],
preflights: [
{
getCSS: () => {
@@ -301,6 +37,277 @@ code,kbd,samp,pre {
},
},
],
presets: [
presetAttributify(),
presetTypography({
cssExtend: {
'.dark a': {
'color': '#9ca0a4',
'text-decoration-color': '#4b5056',
},
'.dark details': {
'background-color': '#191c1e',
},
'a': {
'color': '#223f5dff',
'text-decoration': 'underline',
'text-decoration-color': '#9fa4b1ff',
'text-decoration-style': 'dotted',
'transition': 'color 0.2s ease-in-out',
},
'a:hover': {
'--primary': '207 62% 59%',
'color': 'hsl(var(--primary))',
},
'code::after': {
content: 'normal',
},
'code::before': {
content: 'normal',
},
'details': {
'background-color': '#a6ceef1a',
'margin-bottom': '0.5rem',
'margin-top': '0.5rem',
'padding': '0.5rem 1rem',
},
'h1': {
'margin-bottom': '1rem',
},
'li': {
'margin-bottom': '0',
'margin-top': '0',
},
'li blockquote': {
'margin-bottom': '1rem',
'margin-top': '1rem',
},
'li p': {
'margin-bottom': '0.25rem',
'margin-top': '0.25rem',
},
'ol': {
'margin-bottom': '0.25rem',
'margin-top': '0.25rem',
'padding-inline-start': '1.25rem',
},
'ol li': {
'padding-inline-start': '0.25rem',
},
'p': {
'margin-bottom': '0.5rem',
'margin-top': '0.5rem',
},
'pre': {
'margin-bottom': '0',
'margin-top': '0.5rem',
},
'ul': {
'margin-bottom': '0.25rem',
'margin-top': '0.25rem',
'padding-inline-start': '1.25rem',
},
'ul li': {
'padding-inline-start': '0.25rem',
},
},
}),
presetWind3(),
presetWebFonts({
fonts: {
'grandstander': {
name: 'Grandstander',
},
'mono': {
name: 'DM Mono',
provider: 'none',
},
'mystery-quest': {
name: 'Mystery Quest',
},
'sans': {
name: 'DM Sans Variable',
provider: 'none',
},
'sans-rounded': {
name: 'Comfortaa Variable',
provider: 'none',
},
'serif': {
name: 'DM Serif Display',
provider: 'none',
},
},
}),
presetIcons(),
// NOTICE:
// This cast bridges the preset's bundled UnoCSS declarations to the workspace declarations.
// Version 1.1.4 embeds @unocss/core types in its generated shared declaration file.
// Source: node_modules/@proj-airi/unocss-preset-chromatic/dist/shared-3Rd5ey9i.d.mts.
// Remove this when the package emits imports for UnoCSS types instead of bundling them.
presetChromatic({
baseHue: 220.44,
colors: {
complementary: 180,
primary: 0,
},
}) as unknown as Preset<object>,
],
rules: [
[/^bg-gradient-radial-\[(.+)\]$/, ([, d]) => ({ 'background-image': `radial-gradient(${d})` })],
],
safelist: [
'-ml-8',
'top-0',
'hidden',
'border-0',
'opacity-0',
'group-hover:opacity-100',
'focus:opacity-100',
'lg:flex',
'transition-opacity',
'duration-200',
'ease-in-out',
'[&_span]:focus:opacity-100',
'[&_span_>_span]:focus:outline',
],
shortcuts: {
'bg-gradient-radial': 'bg-gradient-radial-[var(--tw-gradient-stops)]',
},
theme: {
/**
* https://github.com/unocss/unocss/blob/1031312057a3bea1082b7d938eb2ad640f57613a/packages-presets/preset-wind4/src/theme/animate.ts
* https://unocss.dev/presets/wind4#transformdirectives
*/
animation: {
counts: {
progress: 'infinite',
text: 'infinite',
},
durations: {
contentShow: '150ms',
enterFromLeft: '250ms',
enterFromRight: '250ms',
exitToLeft: '250ms',
exitToRight: '250ms',
fadeIn: '200ms',
fadeOut: '200ms',
hide: '100ms',
overlayShow: '150ms',
progress: '1s',
scaleIn: '200ms',
scaleOut: '200ms',
slideDown: '300ms',
slideDownAndFade: '400ms',
slideIn: '150ms',
slideLeftAndFade: '400ms',
slideRightAndFade: '400ms',
slideUp: '300ms',
slideUpAndFade: '400ms',
swipeOut: '100ms',
text: '5s',
},
keyframes: {
contentShow: '{from{opacity:0;transform:translate(-50%, -48%) scale(0.96)}to{opacity:1;transform:translate(-50%, -50%) scale(1)}}',
enterFromLeft: '{from{opacity:0;transform:translateX(-200px)}to{opacity:1;transform:translateX(0)}}',
enterFromRight: '{from{opacity:0;transform:translateX(200px)}to{opacity:1;transform:translateX(0)}}',
exitToLeft: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(-200px)}}',
exitToRight: '{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(200px)}}',
fadeIn: '{from{opacity:0}to{opacity:1}}',
fadeOut: '{from{opacity:1}to{opacity:0}}',
hide: '{from{opacity:1}to{opacity:0}}',
overlayShow: '{from{opacity:0}to{opacity:1}}',
progress: '{0%{background-position:0 0}100%{background-position:30px 30px}}',
scaleIn: '{from{opacity:0;transform:rotateX(-10deg) scale(0.9)}to{opacity:1;transform:rotateX(0deg) scale(1)}}',
scaleOut: '{from{opacity:1;transform:rotateX(0deg) scale(1)}to{opacity:0;transform:rotateX(-10deg) scale(0.95)}}',
slideDown: '{from{height:0}to{height:var(--reka-collapsible-content-height)}}',
slideDownAndFade: '{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}',
slideIn: '{from{transform:translateX(calc(100% + var(--viewport-padding)))}to{transform:translateX(0)}}',
slideLeftAndFade: '{from{opacity:0;transform:translateX(2px)}to{opacity:1;transform:translateX(0)}}',
slideRightAndFade: '{from{opacity:0;transform:translateX(-2px)}to{opacity:1;transform:translateX(0)}}',
slideUp: '{from{height:var(--reka-collapsible-content-height)}to{height:0}}',
slideUpAndFade: '{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}',
swipeOut: '{from{transform:translateX(var(--reka-toast-swipe-end-x))}to{transform:translateX(calc(100% + var(--viewport-padding)))}}',
text: '{0%,100%{background-size:200% 200%;background-position:left center}50%{background-size:200% 200%;background-position:right center}}',
},
timingFns: {
contentShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
enterFromLeft: 'ease',
enterFromRight: 'ease',
exitToLeft: 'ease',
exitToRight: 'ease',
fadeIn: 'ease',
fadeOut: 'ease',
hide: 'ease-in',
overlayShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
progress: 'linear',
scaleIn: 'ease',
scaleOut: 'ease',
slideDown: 'cubic-bezier(0.87, 0, 0.13, 1)',
slideDownAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideIn: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideLeftAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideRightAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideUp: 'cubic-bezier(0.87, 0, 0.13, 1)',
slideUpAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
swipeOut: 'ease-out',
text: 'ease',
},
},
colors: {
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
background: 'hsl(var(--background))',
border: 'hsl(var(--border))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
code: 'hsl(var(--code))',
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
foreground: 'hsl(var(--foreground))',
input: 'hsl(var(--input))',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
ring: 'hsl(var(--ring))',
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
...blackA,
...mauve,
...violet,
...green,
...red,
...grass,
...teal,
...cyan,
...indigo,
...purple,
...slate,
},
fontFamily: {
'sans': `ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-rounded': `"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-serif-halloween': `"Mystery Quest", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-serif-halloween-secondary': `"Grandstander", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
},
},
transformers: [
transformerDirectives(),
transformerVariantGroup(),
+11 -11
View File
@@ -2,8 +2,8 @@ import { defineConfig } from '@moeru/eslint-config'
export default defineConfig({
masknet: false,
perfectionist: true,
preferArrow: false,
perfectionist: false,
sonarjs: false,
sortPackageJsonScripts: false,
typescript: true,
@@ -32,15 +32,12 @@ export default defineConfig({
],
}, {
rules: {
'pnpm/json-valid-catalog': 'off',
'pnpm/json-enforce-catalog': 'off',
'pnpm/yaml-enforce-settings': 'off',
'antfu/import-dedupe': 'error',
// TODO: remove this
'depend/ban-dependencies': 'warn',
'import/order': 'off',
'markdown/require-alt-text': 'off',
'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
// Catches the manual `error instanceof Error ? error.message : ...`
// pattern AGENTS.md forbids. The selector matches a ConditionalExpression
// whose test is `<x> instanceof Error` and whose consequent is `<x>.message`,
@@ -50,29 +47,32 @@ export default defineConfig({
'no-restricted-syntax': [
'error',
{
selector: 'ConditionalExpression[test.type=\'BinaryExpression\'][test.operator=\'instanceof\'][test.right.name=\'Error\'][consequent.type=\'MemberExpression\'][consequent.property.name=\'message\']',
message: 'Avoid `error instanceof Error ? error.message : ...`. Use `errorMessageFrom(error)` from \'@moeru/std\' (or `errorMessageFromUnknown(error, fallback)` from \'@proj-airi/stage-shared\'). Pair with `?? \'fallback\'` when a default is needed.',
selector: 'ConditionalExpression[test.type=\'BinaryExpression\'][test.operator=\'instanceof\'][test.right.name=\'Error\'][consequent.type=\'MemberExpression\'][consequent.property.name=\'message\']',
},
{
message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.',
selector: [
'ImportDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportNamedDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportAllDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ImportExpression[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
].join(', '),
message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.',
},
'TSEnumDeclaration[const=true]',
'TSExportAssignment',
],
'pnpm/json-enforce-catalog': 'off',
'pnpm/json-valid-catalog': 'off',
'pnpm/yaml-enforce-settings': 'off',
// 'sonarjs/cognitive-complexity': 'off',
// 'sonarjs/no-commented-code': 'off',
// 'sonarjs/pseudo-random': 'off',
'style/padding-line-between-statements': 'error',
'vue/prefer-separate-static-class': 'off',
'yaml/plain-scalar': 'off',
'markdown/require-alt-text': 'off',
},
}, {
files: ['server/apps/api/**/*.ts'],
@@ -80,21 +80,21 @@ export default defineConfig({
'no-restricted-syntax': [
'error',
{
selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=/^(mock|doMock)$/][arguments.0.type=\'Literal\'][arguments.0.value=/^(\\.|@proj-airi\\/|~)/]',
message: 'Do not mock internal project modules with vi.mock or vi.doMock. Inject the collaborator through the route, service, or factory boundary and pass a fake or spy in tests.',
selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=/^(mock|doMock)$/][arguments.0.type=\'Literal\'][arguments.0.value=/^(\\.|@proj-airi\\/|~)/]',
},
{
selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=\'hoisted\']',
message: 'Do not use vi.hoisted. If a test needs a collaborator spy, expose an explicit dependency injection point instead of hoisting module mocks.',
selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'vi\'][callee.property.name=\'hoisted\']',
},
{
message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.',
selector: [
'ImportDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportNamedDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ExportAllDeclaration[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
'ImportExpression[source.value=/^\\.{1,2}\\/.*\\.[cm]?[jt]sx?$/]',
].join(', '),
message: 'Omit TypeScript and JavaScript source extensions from relative imports, dynamic imports, and re-exports.',
},
],
},
+2 -1
View File
@@ -3,7 +3,7 @@
"type": "module",
"version": "0.12.0-beta.2",
"private": true,
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@11.24.0",
"description": "LLM powered virtual character",
"author": {
"name": "Moeru AI Project AIRI Team",
@@ -72,6 +72,7 @@
"eslint": "catalog:",
"eslint-plugin-format": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-perfectionist": "catalog:",
"knip": "catalog:",
"nano-staged": "catalog:",
"oxc-minify": "catalog:",
+2 -1
View File
@@ -58,6 +58,7 @@
"@moeru/std": "catalog:"
},
"devDependencies": {
"@types/audioworklet": "catalog:"
"@types/audioworklet": "catalog:",
"vue": "catalog:"
}
}
@@ -1,38 +1,49 @@
import type { CrossWsConstructor } from '.'
import { describe, expect, it, vi } from 'vitest'
import { createCrossWsConnector } from '.'
import { createClient } from '../..'
// ROOT CAUSE:
//
// The mock narrowed message data to two types, but CrossWS exposes unknown data.
// This made the mock constructor incompatible with the production connector contract.
//
// The mock now derives its message event from the public constructor contract.
type CrossWsMessageEvent = Parameters<NonNullable<InstanceType<CrossWsConstructor>['onmessage']>>[0]
const { MockWebSocket } = vi.hoisted(() => {
class MockWebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSING = 2
static readonly CLOSED = 3
static readonly CLOSING = 2
static readonly CONNECTING = 0
static readonly instances: MockWebSocket[] = []
static readonly OPEN = 1
onclose?: (event: { code?: number, reason?: string, wasClean?: boolean }) => void
onerror?: (event: unknown | { error?: Error }) => void
onmessage?: (event: CrossWsMessageEvent) => void
onopen?: () => void
ping = vi.fn()
pong = vi.fn()
readyState = MockWebSocket.CONNECTING
readonly sent: string[] = []
readyState = MockWebSocket.CONNECTING
onclose?: (event: { code?: number, reason?: string, wasClean?: boolean }) => void
onerror?: (event: { error?: Error } | unknown) => void
onmessage?: (event: { data: string | ArrayBuffer }) => void
onopen?: () => void
constructor(readonly url: string | URL, readonly protocols?: string | string[]) {
MockWebSocket.instances.push(this)
}
send(message: string) {
this.sent.push(message)
}
close() {
this.readyState = MockWebSocket.CLOSED
this.onclose?.({ code: 1000, reason: 'closed', wasClean: true })
}
ping = vi.fn()
pong = vi.fn()
send(message: string) {
this.sent.push(message)
}
}
return { MockWebSocket }
@@ -84,8 +95,8 @@ describe('createCrossWsConnector', () => {
wsConstructor: MockWebSocket,
}),
reconnect: {
retries: 0,
onFailed,
retries: 0,
},
})
+101 -85
View File
@@ -18,89 +18,6 @@ export interface CapVitePluginOptions {
capArgs: string[]
}
async function stopCapProcess(current: Result | undefined) {
if (!current) {
return
}
current.kill('SIGINT')
try {
await current
}
catch {
// tinyexec rejects when a process is stopped during a restart.
}
}
function startCapProcess(cwd: string, capArgs: string[], url: URL) {
return x('cap', ['run', ...capArgs], {
throwOnError: false,
nodeOptions: {
cwd,
env: {
CAPACITOR_DEV_SERVER_URL: url.toString(),
},
// NOTICE: cap-vite owns the terminal shortcuts, so cap run should not
// consume stdin while still mirroring its stdout/stderr to the console.
stdio: ['ignore', 'inherit', 'inherit'],
},
})
}
function bindCapViteShortcuts(
onRestart: () => void,
onShutdown: () => Promise<void>,
) {
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
return () => {}
}
process.stdin.resume()
process.stdin.setEncoding('utf8')
readline.emitKeypressEvents(process.stdin)
const shouldRestoreRawMode = !process.stdin.isRaw
if (shouldRestoreRawMode) {
process.stdin.setRawMode(true)
}
async function shutdownFromShortcut() {
try {
await onShutdown()
}
finally {
if (shouldRestoreRawMode) {
process.stdin.setRawMode(false)
}
process.kill(process.pid, 'SIGINT')
}
}
const onKeyPress = (input: string, key: readline.Key) => {
if (key.ctrl && key.name === 'c') {
void shutdownFromShortcut()
return
}
const keyName = key.name?.toLowerCase() ?? input.toLowerCase()
if (!key.ctrl && !key.meta && keyName === 'r') {
onRestart()
}
}
process.stdin.on('keypress', onKeyPress)
return () => {
process.stdin.off('keypress', onKeyPress)
if (shouldRestoreRawMode) {
process.stdin.setRawMode(false)
}
}
}
export function capVitePlugin(options: CapVitePluginOptions): Plugin {
const platform = parseCapacitorPlatform(options.capArgs[0])
if (!platform) {
@@ -110,7 +27,6 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin {
return {
apply: 'serve',
name: 'cap-vite:run-capacitor',
async configureServer(server) {
const resolvedCapArgs = await resolveCapRunArgs(options.capArgs)
const cwd = resolve(server.config.root)
@@ -175,7 +91,23 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin {
}
}
function onWatcherEvent(_event, file) {
/**
* Requests a Capacitor restart after a native project file changes.
*
* Triggering workflow:
*
* `server.watcher`
* -> `all`
* -> `onWatcherEvent`
* -> `requestRestart`
*
* Upstream:
* - `server.watcher`
*
* Downstream:
* - `requestRestart`
*/
function onWatcherEvent(_event: string, file: string) {
if (!shouldRestartForNativeChange(file, resolvedPlatform, cwd)) {
return
}
@@ -219,5 +151,89 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin {
process.once('SIGINT', handleShutdownRequest)
process.once('SIGTERM', handleShutdownRequest)
},
name: 'cap-vite:run-capacitor',
}
}
function bindCapViteShortcuts(
onRestart: () => void,
onShutdown: () => Promise<void>,
) {
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
return () => {}
}
process.stdin.resume()
process.stdin.setEncoding('utf8')
readline.emitKeypressEvents(process.stdin)
const shouldRestoreRawMode = !process.stdin.isRaw
if (shouldRestoreRawMode) {
process.stdin.setRawMode(true)
}
async function shutdownFromShortcut() {
try {
await onShutdown()
}
finally {
if (shouldRestoreRawMode) {
process.stdin.setRawMode(false)
}
process.kill(process.pid, 'SIGINT')
}
}
const onKeyPress = (input: string, key: readline.Key) => {
if (key.ctrl && key.name === 'c') {
void shutdownFromShortcut()
return
}
const keyName = key.name?.toLowerCase() ?? input.toLowerCase()
if (!key.ctrl && !key.meta && keyName === 'r') {
onRestart()
}
}
process.stdin.on('keypress', onKeyPress)
return () => {
process.stdin.off('keypress', onKeyPress)
if (shouldRestoreRawMode) {
process.stdin.setRawMode(false)
}
}
}
function startCapProcess(cwd: string, capArgs: string[], url: URL) {
return x('cap', ['run', ...capArgs], {
nodeOptions: {
cwd,
env: {
CAPACITOR_DEV_SERVER_URL: url.toString(),
},
// NOTICE: cap-vite owns the terminal shortcuts, so cap run should not
// consume stdin while still mirroring its stdout/stderr to the console.
stdio: ['ignore', 'inherit', 'inherit'],
},
throwOnError: false,
})
}
async function stopCapProcess(current: Result | undefined) {
if (!current) {
return
}
current.kill('SIGINT')
try {
await current
}
catch {
// tinyexec rejects when a process is stopped during a restart.
}
}
+1
View File
@@ -63,6 +63,7 @@
},
"devDependencies": {
"@types/hast": "catalog:",
"@types/node": "catalog:",
"@types/unist": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
+1
View File
@@ -4,6 +4,7 @@
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
@@ -47,16 +47,14 @@
"@moeru/std": "catalog:",
"async-mutex": "catalog:",
"nanoid": "catalog:",
"vite": "catalog:"
"vite": "catalog:",
"vue": "catalog:"
},
"inlinedDependencies": {
"@electron-toolkit/preload": "3.0.2",
"@moeru/eventa": "1.0.0",
"async-mutex": "0.5.0",
"nanoid": [
"5.1.11",
"6.0.1"
],
"picomatch": "4.0.4"
"nanoid": "6.0.1",
"picomatch": "4.0.5"
}
}
+2 -1
View File
@@ -43,6 +43,7 @@
"std-env": "catalog:"
},
"devDependencies": {
"@proj-airi/electron-eventa": "workspace:^"
"@proj-airi/electron-eventa": "workspace:^",
"vue": "catalog:"
}
}
@@ -7,6 +7,7 @@ import type {
LlmStreamingControlSignal,
LlmStreamingControlSignalContext,
LlmStreamingControlSignalHandler,
LlmStreamingControlTurn,
LlmStreamingControlTurnDone,
} from './types'
@@ -14,148 +15,10 @@ import { tokenAct, tokenCall, tokenDelay } from './parsers'
import { renderCallManifestPrompt } from './parsers/call'
interface StreamingControlTurnState {
handlers: Map<string, Set<LlmStreamingControlCallHandler>>
callManifests: Map<string, LlmStreamingControlCallManifest>
settle: (result: LlmStreamingControlTurnDone) => void
done: Promise<LlmStreamingControlTurnDone>
}
/**
* Converts parsed signal payload into observer-friendly text.
*
* Use when:
* - Observer logs need a compact human-readable parameter
*
* Notice:
* - Intentionally serializes payloads once
* - Returns undefined for empty CALL payload
*/
function parsedParameter(signal: LlmStreamingControlSignal): string | undefined {
switch (signal.type) {
case 'act':
return JSON.stringify(signal.payload)
case 'call':
return signal.payload != null
? JSON.stringify(signal.payload)
: undefined
case 'delay':
return `${signal.seconds}s`
}
}
function createTurnId() {
return `turn:${
globalThis.crypto?.randomUUID?.()
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}`
}
/**
* Normalizes manifest values before registration.
*
* Notice:
* - Empty names/prompts are rejected
* - Prevents duplicated trim logic
*/
function normalizeManifest(
manifest: LlmStreamingControlCallManifest,
): LlmStreamingControlCallManifest | undefined {
const name = manifest.name.trim()
const prompt = manifest.prompt.trim()
if (!name || !prompt)
return
return {
...manifest,
name,
prompt,
}
}
/**
* Emits observer events safely.
*
* Notice:
* - Observer failures must never break dispatch
*/
function emit(
context: Pick<LlmStreamingControlCallContext, 'observer'> | undefined,
payload: Parameters<NonNullable<LlmStreamingControlCallContext['observer']>>[0],
) {
try {
context?.observer?.(payload)
}
catch {}
}
/**
* Creates isolated turn state.
*
* Notice:
* - Promise resolves once only
* - Prevents accidental double completion
*/
function createTurnState(): StreamingControlTurnState {
let settled = false
let settle!: (result: LlmStreamingControlTurnDone) => void
const done = new Promise<LlmStreamingControlTurnDone>((resolve) => {
settle = (result) => {
if (settled)
return
settled = true
resolve(result)
}
})
return {
handlers: new Map(),
callManifests: new Map(),
settle,
done,
}
}
/**
* Registers handler and keeps cleanup centralized.
*
* Notice:
* - Avoid duplicated Map allocations
* - Removes manifest automatically once empty
*/
function registerHandler<TPayload extends Record<string, unknown>>(
container: Pick<StreamingControlTurnState, 'handlers' | 'callManifests'>,
manifest: LlmStreamingControlCallManifest,
handler: LlmStreamingControlCallHandler<TPayload>,
) {
const normalized = normalizeManifest(manifest)
if (!normalized)
return () => undefined
let set = container.handlers.get(normalized.name)
if (!set) {
set = new Set()
container.handlers.set(normalized.name, set)
}
container.callManifests.set(normalized.name, normalized)
set.add(handler as LlmStreamingControlCallHandler)
return () => {
set!.delete(handler)
if (set!.size === 0) {
container.handlers.delete(normalized.name)
container.callManifests.delete(normalized.name)
}
}
handlers: Map<string, Set<LlmStreamingControlCallHandler>>
settle: (result: LlmStreamingControlTurnDone) => void
}
/**
@@ -223,11 +86,22 @@ export function createStreamingControlParser(
function createTurnApi(
turnId: string,
turn: StreamingControlTurnState,
) {
): LlmStreamingControlTurn {
return {
turnId,
cancel() {
finalizeTurn(turnId, 'cancelled')
},
on<TPayload extends Record<string, unknown> = Record<string, unknown>>(manifest, handler) {
complete() {
finalizeTurn(turnId, 'completed')
},
done: turn.done,
on<TPayload extends Record<string, unknown> = Record<string, unknown>>(
manifest: LlmStreamingControlCallManifest,
handler: LlmStreamingControlCallHandler<TPayload>,
) {
return registerHandler<TPayload>(turn, manifest, handler)
},
@@ -237,21 +111,35 @@ export function createStreamingControlParser(
)
},
complete() {
finalizeTurn(turnId, 'completed')
},
cancel() {
finalizeTurn(turnId, 'cancelled')
},
done: turn.done,
turnId,
}
}
return {
match(input) {
return !!findParser(input)
beginTurn(options) {
// crypto UUID avoids collision under concurrency
const turnId
= options?.turnId?.trim()
|| createTurnId()
const existing = turns.get(turnId)
if (existing)
return createTurnApi(turnId, existing)
const turn = createTurnState()
turns.set(turnId, turn)
return createTurnApi(turnId, turn)
},
cancelTurn(turnId) {
finalizeTurn(turnId, 'cancelled')
},
completeTurn(turnId) {
finalizeTurn(turnId, 'completed')
},
async dispatchWith(special, context) {
@@ -259,8 +147,8 @@ export function createStreamingControlParser(
if (!parser) {
emit(context, {
type: 'rejected',
reason: 'no-matching-parser',
type: 'rejected',
})
return false
@@ -270,23 +158,23 @@ export function createStreamingControlParser(
if (!parsed) {
emit(context, {
type: 'rejected',
reason: 'parse-failed',
parserName: parser.name,
reason: 'parse-failed',
type: 'rejected',
})
return false
}
emit(context, {
type: 'parsed',
parserName: parser.name,
tokenType: parsed.type,
callName:
parsed.type === 'call'
? parsed.name
: undefined,
parameter: parsedParameter(parsed),
parserName: parser.name,
tokenType: parsed.type,
type: 'parsed',
})
const {
@@ -306,9 +194,9 @@ export function createStreamingControlParser(
}
catch (error) {
emit(context, {
type: 'signal-handler-error',
tokenType: parsed.type,
error,
tokenType: parsed.type,
type: 'signal-handler-error',
})
console.warn(
@@ -337,15 +225,15 @@ export function createStreamingControlParser(
]
emit(context, {
type: 'call-handler-count',
count: registeredHandlers.length,
type: 'call-handler-count',
})
if (!registeredHandlers.length) {
emit(context, {
type: 'call-handler-missing',
callName: parsed.name,
payload: parsed.payload,
type: 'call-handler-missing',
})
return true
@@ -356,8 +244,8 @@ export function createStreamingControlParser(
for (const handler of registeredHandlers) {
try {
emit(context, {
type: 'call-handler-start',
callName: parsed.name,
type: 'call-handler-start',
})
await handler(
@@ -366,15 +254,15 @@ export function createStreamingControlParser(
)
emit(context, {
type: 'call-handler-end',
callName: parsed.name,
type: 'call-handler-end',
})
}
catch (error) {
emit(context, {
type: 'call-handler-error',
callName: parsed.name,
error,
type: 'call-handler-error',
})
console.warn(
@@ -387,23 +275,21 @@ export function createStreamingControlParser(
return true
},
match(input) {
return !!findParser(input)
},
on(manifest, handler) {
return registerHandler(
{
handlers,
callManifests,
handlers,
},
manifest,
handler,
)
},
renderManifestPrompt() {
return renderCallManifestPrompt(
[...callManifests.values()],
)
},
onSignal(handler) {
signalHandlers.add(handler)
@@ -412,30 +298,160 @@ export function createStreamingControlParser(
}
},
beginTurn(options) {
// crypto UUID avoids collision under concurrency
const turnId
= options?.turnId?.trim()
|| createTurnId()
const existing = turns.get(turnId)
if (existing)
return createTurnApi(turnId, existing)
const turn = createTurnState()
turns.set(turnId, turn)
return createTurnApi(turnId, turn)
},
completeTurn(turnId) {
finalizeTurn(turnId, 'completed')
},
cancelTurn(turnId) {
finalizeTurn(turnId, 'cancelled')
renderManifestPrompt() {
return renderCallManifestPrompt(
[...callManifests.values()],
)
},
}
}
function createTurnId() {
return `turn:${
globalThis.crypto?.randomUUID?.()
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}`
}
/**
* Creates isolated turn state.
*
* Notice:
* - Promise resolves once only
* - Prevents accidental double completion
*/
function createTurnState(): StreamingControlTurnState {
let settled = false
let settle!: (result: LlmStreamingControlTurnDone) => void
const done = new Promise<LlmStreamingControlTurnDone>((resolve) => {
settle = (result) => {
if (settled)
return
settled = true
resolve(result)
}
})
return {
callManifests: new Map(),
done,
handlers: new Map(),
settle,
}
}
/**
* Emits observer events safely.
*
* Notice:
* - Observer failures must never break dispatch
*/
function emit(
context: Pick<LlmStreamingControlCallContext, 'observer'> | undefined,
payload: Parameters<NonNullable<LlmStreamingControlCallContext['observer']>>[0],
) {
try {
context?.observer?.(payload)
}
catch {}
}
/**
* Normalizes manifest values before registration.
*
* Notice:
* - Empty names/prompts are rejected
* - Prevents duplicated trim logic
*/
function normalizeManifest(
manifest: LlmStreamingControlCallManifest,
): LlmStreamingControlCallManifest | undefined {
const name = manifest.name.trim()
const prompt = manifest.prompt.trim()
if (!name || !prompt)
return
return {
...manifest,
name,
prompt,
}
}
/**
* Converts parsed signal payload into observer-friendly text.
*
* Use when:
* - Observer logs need a compact human-readable parameter
*
* Notice:
* - Intentionally serializes payloads once
* - Returns undefined for empty CALL payload
*/
function parsedParameter(signal: LlmStreamingControlSignal): string | undefined {
switch (signal.type) {
case 'act':
return JSON.stringify(signal.payload)
case 'call':
return signal.payload != null
? JSON.stringify(signal.payload)
: undefined
case 'delay':
return `${signal.seconds}s`
}
}
/**
* Registers one CALL handler and returns its disposer.
*
* Triggering workflow:
*
* {@link createStreamingControlParser}
* -> `LlmStreamingControl.on` or `LlmStreamingControlTurn.on`
* -> {@link registerHandler}
*
* Upstream:
* - {@link LlmStreamingControl}
* - {@link LlmStreamingControlTurn}
*
* Downstream:
* - {@link StreamingControlTurnState} handler and manifest registries
*/
function registerHandler<TPayload extends Record<string, unknown>>(
container: Pick<StreamingControlTurnState, 'callManifests' | 'handlers'>,
manifest: LlmStreamingControlCallManifest,
handler: LlmStreamingControlCallHandler<TPayload>,
) {
const normalized = normalizeManifest(manifest)
if (!normalized)
return () => undefined
let registeredHandlers = container.handlers.get(normalized.name)
if (!registeredHandlers) {
registeredHandlers = new Set()
container.handlers.set(normalized.name, registeredHandlers)
}
container.callManifests.set(normalized.name, normalized)
// The registration API associates each payload type with one opaque CALL name.
// The runtime registry erases that relation before it looks up the name.
const registeredHandler = handler as LlmStreamingControlCallHandler
registeredHandlers.add(registeredHandler)
return () => {
registeredHandlers.delete(registeredHandler)
if (registeredHandlers.size === 0) {
container.handlers.delete(normalized.name)
container.callManifests.delete(normalized.name)
}
}
}
@@ -3,6 +3,7 @@ import type {
ModulePermissionDeclaration as ProtocolModulePermissionDeclaration,
ModulePermissionGrant as ProtocolModulePermissionGrant,
} from '@proj-airi/plugin-protocol/types'
import type { GenericSchema } from 'valibot'
import type { KitDescriptor } from './kits'
@@ -15,6 +16,7 @@ import {
lazy,
literal,
minValue,
null_,
number,
object,
optional,
@@ -110,12 +112,12 @@ export interface HostDataRecord {
* - The recursive union used across shared host data structures
*/
export type HostDataValue
= | null
| string
| number
| boolean
= | boolean
| HostDataArray
| HostDataRecord
| null
| number
| string
/**
* Creates the recursive Valibot schema used for one {@link HostDataValue}.
@@ -129,14 +131,14 @@ export type HostDataValue
* Returns:
* - A Valibot schema covering the full `HostDataValue` recursion
*/
export function createHostDataValueSchema() {
export function createHostDataValueSchema(): GenericSchema<HostDataValue> {
return union([
literal(null),
null_(),
string(),
boolean(),
pipe(number(), finite()),
array(lazy(createHostDataValueSchema)),
pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject)),
pipe(record(string(), lazy(createHostDataValueSchema)), check<HostDataRecord>(isPlainObject)),
])
}
@@ -166,7 +168,10 @@ export const hostDataValueSchema = createHostDataValueSchema()
* Returns:
* - A Valibot schema for one host-safe record
*/
export const hostDataRecordSchema = pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject))
export const hostDataRecordSchema = pipe(
record(string(), lazy(createHostDataValueSchema)),
check<HostDataRecord>(isPlainObject),
)
/**
* Validates one non-negative safe integer used for timestamps and revisions.
@@ -196,6 +201,34 @@ export const nonNegativeIntegerSchema = pipe(number(), safeInteger(), minValue(0
*/
export type ExtensionIdentity = ProtocolExtensionIdentity
/**
* Describes a version-1 extension manifest consumed by `ExtensionHost`.
*
* Extension manifests are the install/session-level package description. Module
* registration happens later during `defineExtension({ setup })`.
*/
export interface ExtensionManifestV1 {
/** Manifest schema version expected by the current host implementation. */
apiVersion: 'v1'
/** Runtime-specific extension entrypoints that the host can resolve and import. */
entrypoints: {
/** Fallback entrypoint used when no runtime-specific path is provided. */
default?: string
/** Electron-specific entrypoint path. */
electron?: string
/** Node-specific entrypoint path. */
node?: string
/** Web-specific entrypoint path. */
web?: string
}
/** Stable extension id used for identity generation and display. */
id: string
/** Manifest kind discriminator used to identify AIRI extension manifests. */
kind: 'manifest.extension.airi.moeru.ai'
/** Package/session permission ceiling that module permissions are capped by. */
permissions: ModulePermissionDeclaration
}
/**
* Re-exports the protocol permission declaration model used by manifests and runtime permission flow.
*
@@ -224,77 +257,49 @@ export type ModulePermissionDeclaration = ProtocolModulePermissionDeclaration
*/
export type ModulePermissionGrant = ProtocolModulePermissionGrant
/**
* Describes a version-1 extension manifest consumed by `ExtensionHost`.
*
* Extension manifests are the install/session-level package description. Module
* registration happens later during `defineExtension({ setup })`.
*/
export interface ExtensionManifestV1 {
/** Manifest schema version expected by the current host implementation. */
apiVersion: 'v1'
/** Manifest kind discriminator used to identify AIRI extension manifests. */
kind: 'manifest.extension.airi.moeru.ai'
/** Stable extension id used for identity generation and display. */
id: string
/** Package/session permission ceiling that module permissions are capped by. */
permissions: ModulePermissionDeclaration
/** Runtime-specific extension entrypoints that the host can resolve and import. */
entrypoints: {
/** Fallback entrypoint used when no runtime-specific path is provided. */
default?: string
/** Electron-specific entrypoint path. */
electron?: string
/** Node-specific entrypoint path. */
node?: string
/** Web-specific entrypoint path. */
web?: string
}
}
const localizableSchema = union([
string(),
object({
key: string(),
fallback: optional(string()),
key: string(),
params: optional(record(string(), union([string(), number(), boolean()]))),
}),
])
const permissionDeclarationSchema = object({
apis: optional(array(object({
key: string(),
actions: array(picklist(['invoke', 'emit'])),
reason: optional(localizableSchema),
label: optional(localizableSchema),
required: optional(boolean()),
}))),
resources: optional(array(object({
key: string(),
actions: array(picklist(['read', 'write', 'subscribe'])),
reason: optional(localizableSchema),
label: optional(localizableSchema),
reason: optional(localizableSchema),
required: optional(boolean()),
}))),
capabilities: optional(array(object({
key: string(),
actions: array(picklist(['wait', 'snapshot'])),
reason: optional(localizableSchema),
label: optional(localizableSchema),
required: optional(boolean()),
}))),
processors: optional(array(object({
key: string(),
actions: array(picklist(['register', 'execute', 'manage'])),
reason: optional(localizableSchema),
label: optional(localizableSchema),
reason: optional(localizableSchema),
required: optional(boolean()),
}))),
pipelines: optional(array(object({
key: string(),
actions: array(picklist(['hook', 'process', 'emit', 'manage'])),
reason: optional(localizableSchema),
key: string(),
label: optional(localizableSchema),
reason: optional(localizableSchema),
required: optional(boolean()),
}))),
processors: optional(array(object({
actions: array(picklist(['register', 'execute', 'manage'])),
key: string(),
label: optional(localizableSchema),
reason: optional(localizableSchema),
required: optional(boolean()),
}))),
resources: optional(array(object({
actions: array(picklist(['read', 'write', 'subscribe'])),
key: string(),
label: optional(localizableSchema),
reason: optional(localizableSchema),
required: optional(boolean()),
}))),
})
@@ -321,12 +326,98 @@ const manifestEntrypointsSchema = object({
*/
export const extensionManifestV1Schema = object({
apiVersion: literal('v1'),
kind: literal('manifest.extension.airi.moeru.ai'),
id: string(),
permissions: permissionDeclarationSchema,
entrypoints: manifestEntrypointsSchema,
id: string(),
kind: literal('manifest.extension.airi.moeru.ai'),
permissions: permissionDeclarationSchema,
})
/**
* Installs one generic host feature into `ExtensionHost`.
*
* Use when:
* - The host should register kits, resources, capabilities, or runtime-specific behavior
*
* Expects:
* - Installation is idempotent for one host instance
* - Contributions keep domain-specific behavior out of the low-level host core
*
* Returns:
* - No value; the contribution mutates the provided install context
*/
export interface ExtensionHostContribution {
install: (context: ExtensionHostInstallContext) => void
}
/**
* Provides the host-owned registration surface that contributions can use during installation.
*
* Use when:
* - Installing a host feature into `ExtensionHost`
* - Registering kits, resources, or capabilities
*
* Expects:
* - Installation happens during `ExtensionHost` construction
*
* Returns:
* - Registration helpers that keep `ExtensionHost` generic while allowing host-specific features
*/
export interface ExtensionHostInstallContext {
announceCapability: (key: string, metadata?: Record<string, unknown>) => void
markCapabilityDegraded: (key: string, metadata?: Record<string, unknown>) => void
markCapabilityReady: (key: string, metadata?: Record<string, unknown>) => void
registerKit: (kit: KitDescriptor) => KitDescriptor
setResourceResolver: <T>(key: string, resolver: () => Promise<T> | T) => void
setResourceValue: <T>(key: string, value: T) => void
unregisterKit: (kitId: string) => KitDescriptor | undefined
withdrawCapability: (key: string, metadata?: Record<string, unknown>) => void
}
/**
* Configures one `ExtensionHost` instance.
*
* Use when:
* - Constructing a host with specific runtime, permission, or contribution behavior
*
* Expects:
* - Omitted fields fall back to the host defaults documented below
*
* Returns:
* - The host bootstrap options consumed by {@link import('../core').ExtensionHost}
*/
export interface ExtensionHostOptions {
/** Installable host features that can register kits, resources, and capabilities. @default [] */
contributions?: ExtensionHostContribution[]
/** Callback that decides the granted permission set for one extension session. */
permissionResolver?: (payload: {
identity: ExtensionIdentity
manifest: ExtensionManifestV1
persisted?: ModulePermissionGrant
requested: ModulePermissionDeclaration
}) => ModulePermissionGrant | Promise<ModulePermissionGrant>
/** Runtime used when callers do not override it per load/start call. @default 'electron' */
runtime?: PluginRuntime
}
/**
* Describes one permission gate that a contribution-owned session API can enforce.
*
* Use when:
* - A contribution method must check host-granted API, resource, or capability access
*
* Expects:
* - The permission key/action pair matches the manifest permission contract
*
* Returns:
* - The permission request consumed by `ExtensionHost.assertPermission(...)`
*/
export interface ExtensionHostPermissionRequest {
action: string
area: 'apis' | 'capabilities' | 'pipelines' | 'processors' | 'resources'
key: string
reason?: string
}
/**
* Configures how the host resolves and loads an extension entrypoint.
*
@@ -346,92 +437,6 @@ export interface ExtensionLoadOptions {
runtime?: PluginRuntime
}
/**
* Configures one `ExtensionHost` instance.
*
* Use when:
* - Constructing a host with specific runtime, permission, or contribution behavior
*
* Expects:
* - Omitted fields fall back to the host defaults documented below
*
* Returns:
* - The host bootstrap options consumed by {@link import('../core').ExtensionHost}
*/
export interface ExtensionHostOptions {
/** Runtime used when callers do not override it per load/start call. @default 'electron' */
runtime?: PluginRuntime
/** Callback that decides the granted permission set for one extension session. */
permissionResolver?: (payload: {
identity: ExtensionIdentity
manifest: ExtensionManifestV1
requested: ModulePermissionDeclaration
persisted?: ModulePermissionGrant
}) => ModulePermissionGrant | Promise<ModulePermissionGrant>
/** Installable host features that can register kits, resources, and capabilities. @default [] */
contributions?: ExtensionHostContribution[]
}
/**
* Describes one permission gate that a contribution-owned session API can enforce.
*
* Use when:
* - A contribution method must check host-granted API, resource, or capability access
*
* Expects:
* - The permission key/action pair matches the manifest permission contract
*
* Returns:
* - The permission request consumed by `ExtensionHost.assertPermission(...)`
*/
export interface ExtensionHostPermissionRequest {
area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines'
action: string
key: string
reason?: string
}
/**
* Provides the host-owned registration surface that contributions can use during installation.
*
* Use when:
* - Installing a host feature into `ExtensionHost`
* - Registering kits, resources, or capabilities
*
* Expects:
* - Installation happens during `ExtensionHost` construction
*
* Returns:
* - Registration helpers that keep `ExtensionHost` generic while allowing host-specific features
*/
export interface ExtensionHostInstallContext {
registerKit: (kit: KitDescriptor) => KitDescriptor
unregisterKit: (kitId: string) => KitDescriptor | undefined
setResourceResolver: <T>(key: string, resolver: () => Promise<T> | T) => void
setResourceValue: <T>(key: string, value: T) => void
announceCapability: (key: string, metadata?: Record<string, unknown>) => void
markCapabilityReady: (key: string, metadata?: Record<string, unknown>) => void
markCapabilityDegraded: (key: string, metadata?: Record<string, unknown>) => void
withdrawCapability: (key: string, metadata?: Record<string, unknown>) => void
}
/**
* Installs one generic host feature into `ExtensionHost`.
*
* Use when:
* - The host should register kits, resources, capabilities, or runtime-specific behavior
*
* Expects:
* - Installation is idempotent for one host instance
* - Contributions keep domain-specific behavior out of the low-level host core
*
* Returns:
* - No value; the contribution mutates the provided install context
*/
export interface ExtensionHostContribution {
install: (context: ExtensionHostInstallContext) => void
}
/**
* Configures one `ExtensionHost.start(...)` call.
*
@@ -5,9 +5,14 @@ export const disposedSessionIds: string[] = []
export default defineExtension({
id: 'test-stoppable-extension-entrypoint',
async setup(ctx) {
const sessionId = ctx.extension.sessionId
if (!sessionId) {
throw new Error('The plugin host did not provide an extension session ID.')
}
ctx.subscriptions.add({
dispose() {
disposedSessionIds.push(ctx.extension.sessionId)
disposedSessionIds.push(sessionId)
},
})
+2
View File
@@ -17,6 +17,7 @@
"exports": {
".": "./src/index.ts",
"./auth": "./src/auth/index.ts",
"./analytics/posthog": "./src/analytics/posthog.ts",
"./beat-sync": "./src/beat-sync/index.ts",
"./global-shortcut": "./src/global-shortcut/index.ts",
"./godot-stage": "./src/godot-stage/index.ts",
@@ -36,6 +37,7 @@
"@vueuse/core": "catalog:",
"gpuu": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"valibot": "catalog:",
"vue": "catalog:"
},
@@ -0,0 +1,25 @@
import type { PostHogConfig } from 'posthog-js'
function isEnvFlagEnabled(value: string | undefined): boolean {
if (value == null)
return false
return /^(?:1|true|t|yes|y|on)$/i.test(value.trim())
}
/** Whether client analytics is enabled for the current Vite build. */
export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG)
/** The shared PostHog project key used by every AIRI client surface. */
export const POSTHOG_PROJECT_KEY
= import.meta.env.VITE_POSTHOG_PROJECT_KEY
?? 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9' // cspell:disable-line
/** Shared PostHog defaults for AIRI single-page applications. */
export const DEFAULT_POSTHOG_CONFIG = {
api_host: 'https://t.airi.build',
// This preset captures page views on history changes. PostHog then also
// captures page leaves, which keeps route-level dwell time measurable.
defaults: '2025-05-24',
person_profiles: 'identified_only',
} as const satisfies Partial<PostHogConfig>
@@ -245,6 +245,9 @@ function toVector3(value: Vec3) {
return new Vector3(value.x, value.y, value.z)
}
const hemisphereLightPosition = new Vector3(0, 1, 0)
const directionalLightPositionVector = computed(() => toVector3(directionalLightPosition.value))
function toVec3(value: Vector3): Vec3 {
return { x: value.x, y: value.y, z: value.z }
}
@@ -817,7 +820,7 @@ defineExpose({
v-else
:color="formatHex(hemisphereSkyColor)"
:ground-color="formatHex(hemisphereGroundColor)"
:position="[0, 1, 0]"
:position="hemisphereLightPosition"
:intensity="hemisphereLightIntensity"
cast-shadow
/>
@@ -829,7 +832,7 @@ defineExpose({
<TresDirectionalLight
ref="dirLightRef"
:color="formatHex(directionalLightColor)"
:position="[directionalLightPosition.x, directionalLightPosition.y, directionalLightPosition.z]"
:position="directionalLightPositionVector"
:intensity="directionalLightIntensity"
cast-shadow
/>
@@ -18,9 +18,9 @@ const props = defineProps<{
zConfig?: AxisConfig
}>()
const x = defineModel('x', { required: false, default: 0 })
const y = defineModel('y', { required: false, default: 0 })
const z = defineModel('z', { required: false, default: 0 })
const x = defineModel<number>('x', { required: false, default: 0 })
const y = defineModel<number>('y', { required: false, default: 0 })
const z = defineModel<number>('z', { required: false, default: 0 })
// Dragging state
const isDragging = ref<'x' | 'y' | 'z' | undefined>()
@@ -16,7 +16,7 @@ export function createFadeAnimator(options: CreateAnimatorOptions): Animator {
.add(elements, {
opacity: [0, 1],
...options,
delay: (_, i) => options.duration / elements.length * (i + 1),
delay: (_: unknown, i = 0) => options.duration / elements.length * (i + 1),
})
return () => {
@@ -12,19 +12,19 @@ export function createFloatAnimator(options: CreateAnimatorOptions): Animator {
const timeline = createTimeline({ loop: options.loop })
.set(elements, {
opacity: 0,
rotateZ: 180,
translateX: '0.55em',
translateY: '1.1em',
rotateZ: 180,
translateZ: 0,
})
.add(elements, {
opacity: [0, 1],
translateY: ['1.1em', 0],
translateX: ['0.55em', 0],
translateZ: 0,
rotateZ: [180, 0],
translateX: ['0.55em', 0],
translateY: ['1.1em', 0],
translateZ: 0,
...options,
delay: (_, i) => options.duration / elements.length * i,
delay: (_: unknown, i = 0) => options.duration / elements.length * i,
})
return () => {
@@ -20,7 +20,7 @@ export function createPopupAnimator(options: CreateAnimatorOptions): Animator {
translateY: ['1.1em', 0],
translateZ: 0,
...options,
delay: (_, i) => options.duration / elements.length * i,
delay: (_: unknown, i = 0) => options.duration / elements.length * i,
})
return () => {
@@ -22,7 +22,7 @@ export function createCutePopupAnimator(options: CreateAnimatorOptions): Animato
translateY: ['1.1em', 0],
translateZ: 0,
...options,
delay: (_, i) => options.duration / elements.length * i,
delay: (_: unknown, i = 0) => options.duration / elements.length * i,
ease: createSpring(),
})
@@ -16,11 +16,11 @@ export function createStackAnimator(options: CreateAnimatorOptions): Animator {
translateZ: 0,
})
.add(elements, {
opacity: [0, 1],
translateX: [40, 0],
translateZ: 0,
opacity: [0, 1],
...options,
delay: (_, i) => options.duration / elements.length * (i + 1),
delay: (_: unknown, i = 0) => options.duration / elements.length * (i + 1),
})
return () => {
@@ -1,6 +1,5 @@
import type { Span, SpanContext, SpanStatusCode } from '@opentelemetry/api'
import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'
import type { TimedEvent } from '@opentelemetry/sdk-trace-base/build/esm/TimedEvent'
import type { SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
import { context, trace } from '@opentelemetry/api'
@@ -13,27 +12,7 @@ export type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
const TRACER_NAME = 'ai.moeru.airi.io-tracer'
const BROADCAST_CHANNEL = 'io-tracer-channel' // TODO: Use simple BroadcastChannel for now
function serializeSpan(span: ReadableSpan): SerializedIOSpan {
const ctx = span.spanContext()
const parentCtx = span.parentSpanContext
return {
traceId: ctx.traceId,
spanId: ctx.spanId,
parentSpanId: parentCtx?.spanId ?? '',
name: span.name,
kind: span.kind,
startTimeNano: String(hrTimeToNanoseconds(span.startTime)),
endTimeNano: span.ended ? String(hrTimeToNanoseconds(span.endTime)) : '0',
attributes: { ...span.attributes },
events: span.events.map((e: TimedEvent) => ({
name: e.name,
timeNano: String(hrTimeToNanoseconds(e.time)),
attributes: { ...e.attributes },
})),
status: { code: span.status.code, message: span.status.message ?? '' },
ended: span.ended,
}
}
type SpanCallback = (span: ReadableSpan) => void
export function deserializeSpan(s: SerializedIOSpan): ReadableSpan {
const nanoToHr = (nano: string): [number, number] => {
@@ -41,42 +20,62 @@ export function deserializeSpan(s: SerializedIOSpan): ReadableSpan {
return [Math.floor(n / 1e9), n % 1e9]
}
const spanCtx: SpanContext = {
traceId: s.traceId,
isRemote: false,
spanId: s.spanId,
traceFlags: 1,
isRemote: false,
traceId: s.traceId,
}
const parentCtx: SpanContext | undefined = s.parentSpanId
? { traceId: s.traceId, spanId: s.parentSpanId, traceFlags: 1, isRemote: false }
? { isRemote: false, spanId: s.parentSpanId, traceFlags: 1, traceId: s.traceId }
: undefined
return {
name: s.name,
kind: s.kind,
spanContext: () => spanCtx,
parentSpanContext: parentCtx,
startTime: nanoToHr(s.startTimeNano),
endTime: nanoToHr(s.endTimeNano),
status: { code: s.status.code as SpanStatusCode, message: s.status.message },
attributes: s.attributes as Record<string, string | number | boolean>,
links: [],
events: s.events.map(e => ({
name: e.name,
time: nanoToHr(e.timeNano),
attributes: e.attributes as Record<string, string | number | boolean>,
droppedAttributesCount: 0,
})),
duration: nanoToHr(String(Number(s.endTimeNano) - Number(s.startTimeNano))),
ended: s.ended,
resource: { attributes: {}, merge: () => ({ attributes: {} }) } as any,
instrumentationScope: { name: TRACER_NAME },
attributes: s.attributes as Record<string, boolean | number | string>,
droppedAttributesCount: 0,
droppedEventsCount: 0,
droppedLinksCount: 0,
duration: nanoToHr(String(Number(s.endTimeNano) - Number(s.startTimeNano))),
ended: s.ended,
endTime: nanoToHr(s.endTimeNano),
events: s.events.map(e => ({
attributes: e.attributes as Record<string, boolean | number | string>,
droppedAttributesCount: 0,
name: e.name,
time: nanoToHr(e.timeNano),
})),
instrumentationScope: { name: TRACER_NAME },
kind: s.kind,
links: [],
name: s.name,
parentSpanContext: parentCtx,
resource: { attributes: {}, merge: () => ({ attributes: {} }) } as any,
spanContext: () => spanCtx,
startTime: nanoToHr(s.startTimeNano),
status: { code: s.status.code as SpanStatusCode, message: s.status.message },
}
}
type SpanCallback = (span: ReadableSpan) => void
function serializeSpan(span: ReadableSpan): SerializedIOSpan {
const ctx = span.spanContext()
const parentCtx = span.parentSpanContext
return {
attributes: { ...span.attributes },
ended: span.ended,
endTimeNano: span.ended ? String(hrTimeToNanoseconds(span.endTime)) : '0',
events: span.events.map(e => ({
attributes: { ...e.attributes },
name: e.name,
timeNano: String(hrTimeToNanoseconds(e.time)),
})),
kind: span.kind,
name: span.name,
parentSpanId: parentCtx?.spanId ?? '',
spanId: ctx.spanId,
startTimeNano: String(hrTimeToNanoseconds(span.startTime)),
status: { code: span.status.code, message: span.status.message ?? '' },
traceId: ctx.traceId,
}
}
let provider: BasicTracerProvider | undefined
let spanCallback: SpanCallback | undefined
@@ -89,17 +88,23 @@ export function createCallbackSpanExporter(): SpanExporter {
spanCallback?.(span)
broadcastChannel?.postMessage({
type: 'span',
span: serializeSpan(span),
type: 'span',
})
}
resultCallback({ code: 0 /* SUCCESS */ })
},
shutdown: () => Promise.resolve(),
forceFlush: () => Promise.resolve(),
shutdown: () => Promise.resolve(),
}
}
export function getIOTracer() {
if (provider)
return provider.getTracer(TRACER_NAME)
return trace.getTracer(TRACER_NAME)
}
export function initIOTracer() {
if (!broadcastChannel)
broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL)
@@ -113,12 +118,6 @@ export function initIOTracer() {
trace.setGlobalTracerProvider(provider)
}
export function getIOTracer() {
if (provider)
return provider.getTracer(TRACER_NAME)
return trace.getTracer(TRACER_NAME)
}
export function onIOSpan(cb: SpanCallback | undefined) {
spanCallback = cb
}
@@ -137,7 +136,7 @@ export function onRemoteIOSpan(cb: SpanCallback): () => void {
}
}
export function startSpan(name: string, parent?: Span, attrs?: Record<string, string | number | boolean>): Span {
export function startSpan(name: string, parent?: Span, attrs?: Record<string, boolean | number | string>): Span {
initIOTracer()
const tracer = getIOTracer()
@@ -18,7 +18,7 @@ vi.mock('@proj-airi/stage-shared', () => ({
isStageTamagotchi: () => false,
}))
vi.mock('../../../../../posthog.config', () => ({
vi.mock('@proj-airi/stage-shared/analytics/posthog', () => ({
DEFAULT_POSTHOG_CONFIG: {},
POSTHOG_PROJECT_KEY: 'test-project-key',
}))
+11 -12
View File
@@ -4,19 +4,10 @@ import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_PROJECT_KEY,
} from '../../../../../posthog.config'
function currentSurface(): 'web' | 'mobile' | 'electron' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
} from '@proj-airi/stage-shared/analytics/posthog'
/** Creates and initializes the default PostHog adapter. */
export function createPosthogAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
@@ -56,10 +47,10 @@ export function createPosthogAdapter(options: AnalyticsAdapterOptions): Analytic
},
registerBuildInfo(buildInfo: AboutBuildInfo) {
posthog.register({
app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev',
app_commit: buildInfo.commit,
app_branch: buildInfo.branch,
app_build_time: buildInfo.builtOn,
app_commit: buildInfo.commit,
app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev',
})
},
resetIdentity() {
@@ -78,3 +69,11 @@ export function createPosthogAdapter(options: AnalyticsAdapterOptions): Analytic
},
}
}
function currentSurface(): 'electron' | 'mobile' | 'web' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import { Rive } from '@rive-app/canvas-lite'
import { breakpointsTailwind, useBreakpoints, useDark } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
import CircleFadeInAnimation from './assets/circle_blink_in_-_loading_(@proj-airi).riv'
import CRT from './CRT.vue'
@@ -243,9 +243,9 @@ const bootMessages = computed<BootMessage[]>(() => [
},
])
const riveCanvas = ref<HTMLCanvasElement>()
const riveCanvas = useTemplateRef<HTMLCanvasElement>('riveCanvas')
const rive = ref<Rive>()
const crtRef = ref<InstanceType<typeof CRT>>()
const crtRef = useTemplateRef<InstanceType<typeof CRT>>('crtRef')
const isDark = useDark()
@@ -271,8 +271,9 @@ function handleUpdateDone(value: boolean) {
// Method to update typing speed for a specific message
function setMessageTypingSpeed(index: number, speed: number) {
if (index >= 0 && index < bootMessages.value.length && speed > 0) {
bootMessages[index].typingSpeed = speed
const message = bootMessages.value[index]
if (message && speed > 0) {
message.typingSpeed = speed
}
}
@@ -376,12 +377,16 @@ async function writeLine<T extends any[]>(
}
onMounted(async () => {
riveCanvas.value.width = Math.max(window.innerWidth, 500) * 2
riveCanvas.value.height = Math.max(window.innerWidth, 500) * 2
const canvas = riveCanvas.value
if (!canvas)
return
canvas.width = Math.max(window.innerWidth, 500) * 2
canvas.height = Math.max(window.innerWidth, 500) * 2
rive.value = new Rive({
src: CircleFadeInAnimation,
canvas: riveCanvas.value,
canvas,
autoplay: true,
artboard: isDark.value ? 'Bold' : 'Bold (Light)',
})
@@ -398,10 +403,14 @@ onMounted(async () => {
})
watch(isDark, () => {
const canvas = riveCanvas.value
if (!canvas)
return
rive.value?.cleanup()
rive.value = new Rive({
src: CircleFadeInAnimation,
canvas: riveCanvas.value,
canvas,
autoplay: true,
artboard: isDark.value ? 'Bold' : 'Bold (Light)',
})
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { useDark } from '@vueuse/core'
import StageTransitionGroup from '../../src/components/StageTransitionGroup.vue'
+9335 -11118
View File
File diff suppressed because it is too large Load Diff
+246 -241
View File
@@ -1,9 +1,11 @@
catalogMode: prefer
minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- '@moeru/*'
- '@proj-airi/*'
- '@vishot/*'
- '@xsai/*'
- '@xsai-apple-speech/*'
- '@xsai-ext/*'
- '@xsai-transformers/*'
@@ -21,12 +23,14 @@ packages:
- '!**/dist/**'
overrides:
'@types/hast': 'catalog:'
array-flatten: npm:@nolyfill/array-flatten@^1.0.44
axios: npm:feaxios@^0.0.23
hono: 4.13.4
'eslint-plugin-sonarjs>typescript': 'catalog:'
hono: 4.13.3
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
isarray: npm:@nolyfill/isarray@^1.0.44
onnxruntime-web: npm:onnxruntime-web@^1.24.3
onnxruntime-web: npm:onnxruntime-web@^1.27.0
safe-buffer: npm:@nolyfill/safe-buffer@^1.0.44
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
@@ -39,27 +43,28 @@ patchedDependencies:
uiohook-napi@1.5.5: patches/uiohook-napi@1.5.5.patch
catalog:
'@alexanderolsen/libsamplerate-js': ^2.1.2
'@alint-js/cli': ^0.4.0
'@alint-js/plugin-js': ^0.4.0
'@antfu/eslint-config': ^8.2.0
'@anthropic-ai/claude-code': ^2.1.204
'@arethetypeswrong/core': ^0.18.2
'@ax-llm/ax': ^19.0.43
'@alint-js/cli': ^0.6.0
'@alint-js/plugin-js': ^0.6.0
'@antfu/eslint-config': 8.2.0
'@anthropic-ai/claude-code': ^2.1.241
'@arethetypeswrong/core': ^0.18.5
'@ax-llm/ax': ^24.0.5
'@better-auth/cli': ^1.4.22
'@better-auth/drizzle-adapter': ^1.6.25
'@better-auth/drizzle-adapter': 1.6.25
'@better-auth/oauth-provider': 1.6.25
'@better-fetch/fetch': ^1.3.1
'@capacitor/android': ^8.3.1
'@capacitor/app': ^8.1.0
'@capacitor/barcode-scanner': ^3.0.2
'@capacitor/cli': ^8.3.1
'@capacitor/core': ^8.3.1
'@capacitor/ios': ^8.3.1
'@capacitor/local-notifications': ^8.0.2
'@capacitor/android': ^8.5.0
'@capacitor/app': ^8.1.1
'@capacitor/barcode-scanner': ^3.1.1
'@capacitor/cli': ^8.5.0
'@capacitor/core': ^8.5.0
'@capacitor/ios': ^8.5.0
'@capacitor/local-notifications': ^8.3.1
'@date-fns/utc': ^2.1.1
'@discordjs/voice': ^0.19.2
'@dotenvx/dotenvx': ^1.61.1
'@electric-sql/pglite': ^0.4.4
'@dotenvx/dotenvx': ^2.21.0
'@electric-sql/pglite': ^0.5.6
'@electric-sql/pglite-pgvector': 0.0.7
'@electron-toolkit/eslint-config-ts': ^3.1.0
'@electron-toolkit/preload': ^3.0.2
'@electron-toolkit/tsconfig': ^2.0.0
@@ -69,82 +74,82 @@ catalog:
'@esotericsoftware/spine-webgl-4-0': npm:@esotericsoftware/spine-webgl@~4.0.31
'@esotericsoftware/spine-webgl-4-1': npm:@esotericsoftware/spine-webgl@~4.1.56
'@ffmpeg-installer/ffmpeg': ^1.1.0
'@fontsource-variable/comfortaa': ^5.2.8
'@fontsource-variable/dm-sans': ^5.2.8
'@fontsource-variable/jura': ^5.2.7
'@fontsource-variable/nunito': ^5.2.7
'@fontsource-variable/quicksand': ^5.2.10
'@fontsource-variable/urbanist': ^5.2.7
'@fontsource/dm-mono': ^5.2.7
'@fontsource/dm-serif-display': ^5.2.8
'@fontsource/gugi': ^5.2.7
'@fontsource/kiwi-maru': ^5.2.8
'@fontsource/m-plus-rounded-1c': ^5.2.10
'@formkit/auto-animate': ^0.9.0
'@fontsource-variable/comfortaa': ^5.3.0
'@fontsource-variable/dm-sans': ^5.3.0
'@fontsource-variable/jura': ^5.3.0
'@fontsource-variable/nunito': ^5.3.0
'@fontsource-variable/quicksand': ^5.3.0
'@fontsource-variable/urbanist': ^5.3.0
'@fontsource/dm-mono': ^5.3.0
'@fontsource/dm-serif-display': ^5.3.0
'@fontsource/gugi': ^5.3.0
'@fontsource/kiwi-maru': ^5.3.0
'@fontsource/m-plus-rounded-1c': ^5.3.0
'@formkit/auto-animate': ^0.10.0
'@grammyjs/files': ^1.2.0
'@guiiai/logg': ^1.2.11
'@histoire/plugin-vue': 1.0.0-beta.1
'@hono/node-server': ^1.19.14
'@hono/node-ws': ^1.3.0
'@hono/node-server': ^2.1.1
'@hono/node-ws': ^1.3.1
'@hono/otel': ^1.1.2
'@huggingface/transformers': ^3.8.1
'@iconify-json/carbon': ^1.2.20
'@huggingface/transformers': 3.8.1
'@iconify-json/carbon': ^1.2.25
'@iconify-json/eos-icons': ^1.2.4
'@iconify-json/line-md': ^1.2.16
'@iconify-json/logos': ^1.2.11
'@iconify-json/lucide': ^1.2.102
'@iconify-json/material-symbols': ^1.2.67
'@iconify-json/logos': ^1.2.13
'@iconify-json/lucide': ^1.2.125
'@iconify-json/material-symbols': ^1.2.89
'@iconify-json/mdi': ^1.2.3
'@iconify-json/mingcute': ^1.2.7
'@iconify-json/mingcute': ^1.2.8
'@iconify-json/ph': ^1.2.2
'@iconify-json/simple-icons': ^1.2.78
'@iconify-json/solar': ^1.2.5
'@iconify-json/simple-icons': ^1.2.93
'@iconify-json/solar': ^1.2.9
'@iconify-json/svg-spinners': ^1.2.4
'@iconify-json/tabler': ^1.2.33
'@iconify-json/tabler': ^1.2.38
'@iconify-json/twemoji': ^1.2.5
'@iconify-json/vscode-icons': ^1.2.45
'@iconify/utils': ^3.1.0
'@iconify/vue': ^5.0.0
'@intlify/core': ^11.3.2
'@intlify/unplugin-vue-i18n': ^11.0.7
'@langfuse/otel': ^5.4.0
'@langfuse/tracing': ^5.4.0
'@laplace.live/event-bridge-sdk': ^1.0.22
'@laplace.live/event-types': ^2.0.14
'@iconify-json/vscode-icons': ^1.2.74
'@iconify/utils': ^3.1.4
'@iconify/vue': ^5.0.1
'@intlify/core': ^11.4.9
'@intlify/unplugin-vue-i18n': ^11.2.5
'@langfuse/otel': ^5.10.1
'@langfuse/tracing': ^5.10.1
'@laplace.live/event-bridge-sdk': ^1.1.0
'@laplace.live/event-types': ^2.0.21
'@lemonneko/crop-empty-pixels': 0.1.1
'@mdit/plugin-footnote': ^0.23.2
'@mdit/plugin-tasklist': ^0.23.2
'@mediapipe/tasks-vision': ^0.10.34
'@modelcontextprotocol/sdk': ^1.29.0
'@mdit/plugin-footnote': ^1.1.0
'@mdit/plugin-tasklist': ^1.1.0
'@mediapipe/tasks-vision': ^1.0.1
'@modelcontextprotocol/sdk': ^1.30.0
'@moeru/eslint-config': 0.1.0-beta.19
'@moeru/eventa': 1.0.0
'@moeru/std': 0.1.0-beta.17
'@moeru/three-mmd': 0.1.0-beta.7
'@moeru/three-mmd-physics-ammo': 0.1.0-beta.7
'@napi-rs/image': ^1.12.0
'@napi-rs/image': ^1.14.0
'@nekopaw/tempora': 0.4.0-alpha.1
'@opentelemetry/api': ^1.9.1
'@opentelemetry/api-logs': ^0.215.0
'@opentelemetry/auto-instrumentations-node': ^0.73.0
'@opentelemetry/core': ^2.7.0
'@opentelemetry/exporter-logs-otlp-proto': ^0.215.0
'@opentelemetry/exporter-metrics-otlp-proto': ^0.215.0
'@opentelemetry/exporter-trace-otlp-proto': ^0.215.0
'@opentelemetry/instrumentation': ^0.215.0
'@opentelemetry/instrumentation-http': ^0.215.0
'@opentelemetry/instrumentation-ioredis': ^0.63.0
'@opentelemetry/instrumentation-pg': ^0.67.0
'@opentelemetry/instrumentation-runtime-node': ^0.28.0
'@opentelemetry/instrumentation-undici': ^0.25.0
'@opentelemetry/resources': ^2.7.0
'@opentelemetry/sdk-logs': ^0.215.0
'@opentelemetry/sdk-metrics': ^2.7.0
'@opentelemetry/sdk-node': ^0.215.0
'@opentelemetry/sdk-trace-base': ^2.7.0
'@opentelemetry/sdk-trace-node': ^2.7.0
'@opentelemetry/semantic-conventions': ^1.40.0
'@pinia/colada': ^1.2.1
'@pinia/testing': ^1.0.3
'@opentelemetry/api-logs': ^0.221.0
'@opentelemetry/auto-instrumentations-node': ^0.79.0
'@opentelemetry/core': ^2.10.0
'@opentelemetry/exporter-logs-otlp-proto': ^0.221.0
'@opentelemetry/exporter-metrics-otlp-proto': ^0.221.0
'@opentelemetry/exporter-trace-otlp-proto': ^0.221.0
'@opentelemetry/instrumentation': ^0.221.0
'@opentelemetry/instrumentation-http': ^0.221.0
'@opentelemetry/instrumentation-ioredis': ^0.69.0
'@opentelemetry/instrumentation-pg': ^0.73.0
'@opentelemetry/instrumentation-runtime-node': ^0.34.0
'@opentelemetry/instrumentation-undici': ^0.31.0
'@opentelemetry/resources': ^2.10.0
'@opentelemetry/sdk-logs': ^0.221.0
'@opentelemetry/sdk-metrics': ^2.10.0
'@opentelemetry/sdk-node': ^0.221.0
'@opentelemetry/sdk-trace-base': ^2.10.0
'@opentelemetry/sdk-trace-node': ^2.10.0
'@opentelemetry/semantic-conventions': ^1.43.0
'@pinia/colada': ^1.4.2
'@pinia/testing': ^2.0.1
'@pixi/app': ^6.5.10
'@pixi/constants': ^6.5.10
'@pixi/core': ^6.5.10
@@ -158,66 +163,66 @@ catalog:
'@pixi/sprite': ^6.5.10
'@pixi/ticker': ^6.5.10
'@pixi/utils': ^6.5.10
'@pixiv/three-vrm': ^3.5.2
'@pixiv/three-vrm-animation': ^3.5.2
'@pixiv/three-vrm-core': ^3.5.2
'@pixiv/three-vrm': ^3.5.5
'@pixiv/three-vrm-animation': ^3.5.5
'@pixiv/three-vrm-core': ^3.5.5
'@pnpm/find-workspace-dir': ^1000.1.5
'@proj-airi/chromatic': ^1.1.1
'@proj-airi/drizzle-duckdb-wasm': ^0.5.0
'@proj-airi/chromatic': ^1.1.4
'@proj-airi/drizzle-duckdb-wasm': ^0.6.0
'@proj-airi/iconify-meteocons': ^0.1.5
'@proj-airi/lobe-icons': ^1.0.19
'@proj-airi/unocss-preset-chromatic': ^1.1.1
'@proj-airi/unocss-preset-chromatic': ^1.1.4
'@proj-airi/unplugin-fetch': ^0.2.3
'@proj-airi/unplugin-live2d-sdk': ^0.1.7
'@radix-ui/colors': ^3.0.0
'@ricky0123/vad-web': ^0.0.30
'@rive-app/canvas-lite': ^2.37.2
'@shikijs/markdown-it': ^4.0.2
'@shikijs/rehype': ^4.0.2
'@rive-app/canvas-lite': ^2.40.1
'@shikijs/markdown-it': ^4.4.3
'@shikijs/rehype': ^4.4.3
'@shopify/draggable': ^1.2.1
'@snazzah/davey': ^0.1.11
'@snazzah/davey': ^0.1.12
'@takumi-rs/image-response': 1.0.0-beta.20
'@tresjs/cientos': ^5.7.0
'@tresjs/core': ^5.8.0
'@tresjs/post-processing': ^3.7.1
'@types/audioworklet': ^0.0.97
'@tresjs/cientos': ^5.8.1
'@tresjs/core': ^5.8.3
'@tresjs/post-processing': ^3.7.4
'@types/audioworklet': ^0.0.100
'@types/culori': ^4.0.1
'@types/d3': ^7.4.3
'@types/hast': ^3.0.4
'@types/hast': ^3.0.5
'@types/ioredis-mock': ^8.2.8
'@types/markdown-it': ^14.1.2
'@types/markdown-it': ^14.2.0
'@types/mdast': ^4.0.4
'@types/node': ^24.12.2
'@types/node': ^26.2.0
'@types/nprogress': ^0.2.3
'@types/pg': ^8.20.0
'@types/semver': ^7.7.1
'@types/pg': ^8.23.1
'@types/semver': ^7.8.0
'@types/splitpanes': ^2.2.6
'@types/three': ^0.184.0
'@types/three': ^0.185.4
'@types/unist': ^3.0.3
'@types/vscode': ^1.116.0
'@types/vscode': ^1.134.0
'@types/whatwg-mimetype': ^5.0.0
'@types/ws': ^8.18.1
'@types/xast': ^2.0.4
'@types/yauzl': ^2.10.3
'@unocss/eslint-config': ^66.6.8
'@unocss/eslint-plugin': ^66.6.8
'@unocss/preset-mini': ^66.6.8
'@unocss/preset-web-fonts': ^66.6.8
'@unocss/reset': ^66.6.8
'@types/yauzl': ^3.4.0
'@unocss/eslint-config': 66.7.5
'@unocss/eslint-plugin': 66.7.5
'@unocss/preset-mini': 66.7.5
'@unocss/preset-web-fonts': 66.7.5
'@unocss/reset': 66.7.5
'@valibot/to-json-schema': 1.0.0-rc.0
'@velin-dev/core': ^0.4.0
'@vishot/cli': ^0.2.0
'@vishot/mockup-desktop-vue': ^0.2.0
'@vishot/renderer-browser': ^0.2.0
'@vishot/source-electron': ^0.2.0
'@vitejs/plugin-vue': ^6.0.6
'@vue-macros/volar': ^3.1.2
'@vue/test-utils': ^2.4.6
'@vitejs/plugin-vue': ^6.0.8
'@vue-macros/volar': ^3.1.4
'@vue/test-utils': ^2.4.11
'@vue/tsconfig': ^0.9.1
'@vueuse/core': ^14.2.1
'@vueuse/core': ^14.4.0
'@vueuse/motion': ^3.0.3
'@vueuse/shared': ^14.2.1
'@webgpu/types': ^0.1.69
'@vueuse/shared': ^14.4.0
'@webgpu/types': ^0.1.72
'@wxt-dev/module-vue': ^1.0.3
'@xsai-apple-speech/transcription': 0.1.3
'@xsai-apple-speech/transcription-electron-plugin': 0.1.3
@@ -239,80 +244,82 @@ catalog:
'@xsai/stream-transcription': 0.5.0-beta.8
'@xsai/tool': 0.5.0-beta.8
'@xsai/utils-chat': 0.5.0-beta.8
alien-signals: ^3.1.2
animejs: ^4.3.6
alien-signals: ^3.2.1
animejs: ^4.5.0
async-mutex: 0.5.0
awilix: ^13.0.3
best-effort-json-parser: ^1.4.0
better-auth: ^1.6.25
builder-util-runtime: ^9.5.1
bumpp: ^11.0.1
awilix: ^13.0.5
best-effort-json-parser: ^1.5.1
better-auth: 1.6.25
builder-util-runtime: ^9.7.0
bumpp: ^12.2.1
cac: ^7.0.0
canvas: ^3.2.3
capacitor-native-settings: ^8.1.0
capacitor-native-settings: ^8.2.0
chess.js: ^1.4.0
clustr: ^1.0.2
colorjs.io: ^0.6.1
crossws: ^0.4.5
colorjs.io: ^0.7.1
crossws: ^0.4.12
csstype: ^3.2.3
culori: ^4.0.2
d3: 7.9.0
date-fns: ^4.1.0
date-fns: ^4.4.0
debug: ^4.4.3
defu: ^6.1.7
destr: ^2.0.5
discord.js: ^14.26.3
dompurify: ^3.4.0
driver.js: ^1.4.0
discord.js: ^14.27.0
dompurify: ^3.4.14
driver.js: ^1.8.0
drizzle-kit: ^0.31.10
drizzle-orm: ^0.45.2
drizzle-valibot: ^0.4.2
electron: ^41.2.1
electron-builder: ^26.8.1
electron: ^41.10.6
electron-builder: ^26.15.3
electron-click-drag-plugin: ^2.0.2
electron-updater: ^6.8.3
electron-updater: ^6.8.9
electron-vite: ^5.0.0
embla-carousel-autoplay: 9.0.0-rc01
embla-carousel-vue: 9.0.0-rc02
es-toolkit: ^1.45.1
eslint: ^10.2.1
es-toolkit: ^1.51.0
eslint: ^10.9.0
eslint-plugin-format: ^2.0.1
eslint-plugin-oxlint: ^1.60.0
eslint-plugin-oxlint: ^1.79.0
eslint-plugin-perfectionist: ^5.10.1
eventemitter3: ^5.0.4
fflate: ^0.8.3
floating-vue: ^5.2.2
fluent-ffmpeg: ^2.1.3
get-port-please: ^3.2.0
gpuu: ^1.0.7
grammy: ^1.42.0
grammy: ^1.45.1
gray-matter: ^4.0.3
h3: 2.0.1-rc.20
h3: 2.0.1-rc.29
hfup: ^1.0.4
histoire: 1.0.0-beta.1
hono: 4.13.4
hono: 4.13.3
hono-rate-limiter: ^0.5.3
html2canvas: ^1.4.1
idb-keyval: ^6.2.2
idb-keyval: ^6.3.0
injeca: ^0.2.0
ioredis: ^5.10.1
ioredis: ^6.0.0
ioredis-mock: ^8.13.1
is-network-error: ^1.3.1
isolated-vm: ^7.0.0
jose: ^6.2.2
jsdom: ^29.0.2
is-network-error: ^1.3.2
isolated-vm: ^7.0.1
jose: ^6.2.10
jsdom: ^30.0.1
jszip: ^3.10.1
knip: ^6.4.1
knip: ^6.32.2
kokoro-js: ^1.2.1
less: ^4.6.4
libsodium-wrappers: ^0.8.3
kysely: 0.29.4
less: ^4.9.0
libsodium-wrappers: ^0.8.4
localforage: ^1.10.0
mark.js: ^8.11.1
markdown-it: ^14.1.1
markdown-it-anchor: ^9.2.0
mediabunny: ^1.40.1
markdown-it: 14.1.1
markdown-it-anchor: ^9.2.1
mediabunny: ^1.55.2
meta-png: ^1.0.6
minecraft-data: 3.102.3
mineflayer: ^4.33.0
mineflayer: ^4.37.1
mineflayer-armor-manager: ^2.0.1
mineflayer-auto-eat: ^5.0.3
mineflayer-collectblock: ^1.6.0
@@ -321,164 +328,162 @@ catalog:
mineflayer-tool: ^1.2.0
minisearch: ^7.2.0
mkcert: ^3.2.0
motion-v: ^2.2.1
motion-v: ^2.4.0
nano-staged: ^1.0.2
nanoid: ^5.1.9
node-pty: npm:@lydell/node-pty@^1.2.0-beta.12
nanoid: ^6.0.1
node-pty: npm:@lydell/node-pty@^1.2.0-beta.15
node-vibrant: ^4.0.4
nprogress: ^0.2.0
ofetch: ^1.5.1
onnxruntime-web: ^1.24.3
onnxruntime-web: ^1.27.0
opusscript: ^0.1.1
oxc-minify: ^0.126.0
oxlint: ^1.60.0
p-limit: ^7.3.0
oxc-minify: ^0.146.0
oxlint: ^1.79.0
p-limit: ^7.3.1
pathe: ^2.0.3
pg: ^8.20.0
pinia: ^3.0.4
pinia-plugin-synced: ^0.1.3
pg: ^8.23.0
pinia: ^4.0.3
pinia-plugin-synced: ^0.1.4
pixi-filters: ^4.2.0
pixi-live2d-display: ^0.4.0
playwright: ^1.60.0
playwright: ^1.62.1
popmotion: ^11.0.5
postcss: ^8.5.10
postcss: ^8.5.26
postgres: ^3.4.9
posthog-js: 1.306.1
posthog-node: ^5.39.4
postprocessing: ^6.39.1
posthog-node: ^5.50.0
postprocessing: ^6.39.4
prismarine-block: ^1.23.0
prismarine-entity: ^2.6.0
prismarine-item: ^1.18.0
prismarine-recipe: ^1.5.0
prismarine-viewer: ^1.33.0
prismarine-windows: ^2.10.0
publint: ^0.3.18
publint: ^0.3.24
rehype-katex: ^7.0.1
rehype-parse: ^9.0.1
rehype-stringify: ^10.0.1
reka-ui: ^2.10.1
reka-ui: ^2.10.3
remark-math: ^6.0.0
remark-parse: ^11.0.0
remark-rehype: ^11.1.2
replicate: ^1.4.0
resend: ^6.12.2
rollup: ^4.60.1
semver: ^7.7.4
shiki: ^4.0.2
resend: ^6.22.0
rollup: ^4.62.5
semver: ^7.8.5
shiki: ^4.4.3
simple-git-hooks: ^2.13.1
splitpanes: ^4.0.4
sponsorkit: ^17.1.0
splitpanes: ^4.1.2
sponsorkit: 17.1.0
sponsors-svg: ^0.3.0
srvx: ^0.11.15
std-env: ^4.1.0
stockfish: ^18.0.7
stripe: ^22.0.2
srvx: ^0.12.7
std-env: ^4.2.0
stockfish: ^18.0.8
stripe: ^22.5.0
superjson: ^2.2.6
taze: ^19.11.0
taze: ^21.1.0
telegram: ^2.26.22
three: ^0.184.0
three: ^0.185.1
three-stdlib: ^2.36.1
tinyexec: ^1.1.1
tinyglobby: ^0.2.16
tsdown: ^0.21.9
tsx: ^4.21.0
turbo: ^2.9.6
typescript: ^5.9.3
tinyexec: ^1.3.0
tinyglobby: ^0.2.17
tsdown: ^0.22.14
tsx: ^4.23.12
turbo: ^2.10.11
typescript: ^6.0.3
uiohook-napi: ^1.5.5
uncrypto: ^0.1.3
unified: ^11.0.5
unist-builder: ^4.0.0
unist-util-visit: ^5.1.0
unocss: ^66.6.8
unocss: 66.7.5
unocss-preset-scrollbar: ^4.0.0
unplugin-basemove: 0.0.1
unplugin-info: ^1.3.2
unplugin-lightningcss: ^0.4.5
unplugin-lightningcss: ^0.5.0
unplugin-raw: ^0.7.0
unplugin-unused: ^0.5.7
unplugin-unused: ^0.6.0
unplugin-vue-router: ^0.19.2
unplugin-yaml: ^4.1.0
unplugin-yaml: ^4.2.1
unstorage: ^1.17.5
uqr: ^0.1.3
uuid: ^13.0.0
valibot: ^1.3.1
uuid: ^14.0.2
valibot: ^1.4.2
vaul-vue: ^0.4.1
vec3: ^0.2.0
vieval: ^0.0.5
virtua: ^0.50.3
vite: ^8.0.8
vieval: ^0.0.12
virtua: ^0.50.4
vite: ^8.2.2
vite-bundle-visualizer: ^1.2.1
vite-plugin-inspect: 12.0.0-beta.1
vite-plugin-mkcert: ^2.0.0
vite-plugin-pwa: ^1.2.0
vite-plugin-vue-devtools: ^8.1.1
vite-plugin-mkcert: ^2.1.0
vite-plugin-pwa: ^1.3.0
vite-plugin-vue-devtools: ^8.2.1
vite-plugin-vue-layouts: ^0.11.0
vitepress: 2.0.0-alpha.17
vitest-browser-vue: ^2.1.0
vscode-ext-gen: ^1.6.0
vue: ^3.5.32
vue: ^3.5.41
vue-demi: ^0.14.10
vue-i18n: ^11.3.2
vue-macros: ^3.1.2
vue-router: ^5.0.4
vue-i18n: ^11.4.9
vue-macros: ^3.1.4
vue-router: ^5.2.0
vue-sonner: ^2.0.9
vue-tsc: ^3.2.6
vue-tsc: ^3.3.11
wavefile: ^11.0.0
web-haptics: ^0.0.6
whatwg-mimetype: ^5.0.0
wlipsync: ^1.3.0
workbox-window: ^7.4.0
ws: ^8.20.0
wxt: ^0.20.24
wlipsync: ^1.3.1
workbox-window: ^7.4.1
ws: ^8.21.3
wxt: ^0.21.4
xast-util-to-xml: ^4.0.0
xastscript: ^4.0.0
xsschema: 0.5.0-beta.8
yaml: ^2.8.3
yauzl: ^3.3.0
zod: ^4.3.6
yaml: ^2.9.0
yauzl: ^3.4.0
zod: ^4.4.3
zod-to-json-schema: ^3.25.2
catalogs:
vitest:
'@vitest/browser-playwright': ^4.1.4
'@vitest/coverage-v8': ^4.1.4
vitest: ^4.1.4
'@vitest/browser-playwright': ^4.1.11
'@vitest/coverage-v8': ^4.1.11
vitest: ^4.1.11
xsai:
unspeech: ^0.1.14
unspeech: ^0.1.16
ignoredBuiltDependencies:
- '@ax-llm/ax'
- '@prisma/client'
- better-sqlite3
- simple-git-hooks # [workaround] With `shellEmulator: true`, simple-git-hooks install may fail when no .git dir exists.
onlyBuiltDependencies:
- '@anthropic-ai/claude-code'
- '@discordjs/opus'
- '@ffmpeg-installer/darwin-arm64'
- '@ffmpeg-installer/linux-x64'
- '@napi-rs/image'
- '@parcel/watcher'
- bufferutil
- canvas
- core-js
- electron
- electron-click-drag-plugin
- electron-winstaller
- es5-ext
- esbuild
- ffmpeg-static
- isolated-vm
- less
- msw
- node-pty
- onnxruntime-node
- protobufjs
- sharp
- spawn-sync
- stockfish
- uiohook-napi
- utf-8-validate
- vue-demi
allowBuilds:
'@anthropic-ai/claude-code': true
'@ax-llm/ax': false
'@discordjs/opus': true
'@ffmpeg-installer/darwin-arm64': true
'@ffmpeg-installer/linux-x64': true
'@napi-rs/image': true
'@parcel/watcher': true
'@prisma/client': false
better-sqlite3: false
bufferutil: true
canvas: true
core-js: true
electron: true
electron-click-drag-plugin: true
electron-winstaller: true
es5-ext: true
esbuild: true
ffmpeg-static: true
isolated-vm: true
less: true
msw: true
node-pty: true
onnxruntime-node: true
protobufjs: true
sharp: true
simple-git-hooks: false # [workaround] With `shellEmulator: true`, simple-git-hooks install may fail when no .git dir exists.
spawn-sync: true
stockfish: true
uiohook-napi: true
utf-8-validate: true
vue-demi: true
packageExtensions:
'@formkit/auto-animate':
peerDependencies:
-33
View File
@@ -1,33 +0,0 @@
/// <reference types="vite/client" />
import type { PostHogConfig } from 'posthog-js'
function isEnvFlagEnabled(value: string | undefined): boolean {
if (value == null)
return false
return /^(?:1|true|t|yes|y|on)$/i.test(value.trim())
}
// For Release workflows set `VITE_ENABLE_POSTHOG=true`.
export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG)
// Single PostHog project for every AIRI surface (web / desktop / mobile).
// Platforms are told apart by the `app_surface` super property set at init, not
// by routing to separate per-platform projects.
export const POSTHOG_PROJECT_KEY
= import.meta.env.VITE_POSTHOG_PROJECT_KEY
?? 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9' // cspell:disable-line
export const DEFAULT_POSTHOG_CONFIG = {
api_host: 'https://t.airi.build',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
// Without this, posthog-js only fires `$pageview` on the initial page load.
// Every AIRI surface is an SPA (vue-router / VitePress client routing), so
// route changes would be invisible in PostHog. The '2025-05-24' defaults
// preset switches `capture_pageview` to 'history_change' — and because
// `capture_pageleave` defaults to 'if_capture_pageview', every surface
// that spreads this config also starts emitting `$pageleave`. That is
// intentional: pageleave is what makes route-level dwell time queryable.
defaults: '2025-05-24',
} as const satisfies Partial<PostHogConfig>
+12 -10
View File
@@ -119,9 +119,9 @@ else {
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: env.npm_package_version || '0.0.0',
'service.namespace': serviceNamespace,
'service.instance.id': instanceId,
'deployment.environment': env.NODE_ENV || 'development',
'service.instance.id': instanceId,
'service.namespace': serviceNamespace,
})
// Traces fan out to independent processors. OTLP (Grafana Tempo) sees every
@@ -131,8 +131,8 @@ else {
const spanProcessors = []
if (otlpEndpoint) {
spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
headers,
url: `${otlpEndpoint}/v1/traces`,
})))
}
// Langfuse runs on its OWN TracerProvider, isolated from the global NodeSDK
@@ -160,12 +160,12 @@ else {
resource,
sampler: new AlwaysOnSampler(),
spanProcessors: [new LangfuseSpanProcessor({
publicKey: env.LANGFUSE_PUBLIC_KEY,
secretKey: env.LANGFUSE_SECRET_KEY,
baseUrl: env.LANGFUSE_BASE_URL,
// Railway is long-running → batched. Flushed via
// langfuseProvider.shutdown() in the SIGTERM handler below.
exportMode: 'batched',
publicKey: env.LANGFUSE_PUBLIC_KEY,
secretKey: env.LANGFUSE_SECRET_KEY,
// Full override of Langfuse's default filter. Export ONLY spans the
// @langfuse/tracing SDK created (they carry `langfuse.*` attributes
// such as `langfuse.observation.type`). This provider should only ever
@@ -196,18 +196,20 @@ else {
// pointed at an empty URL.
...(otlpEndpoint
? {
logRecordProcessors: [new BatchLogRecordProcessor({
exporter: new OTLPLogExporter({
headers,
url: `${otlpEndpoint}/v1/logs`,
}),
})],
metricReaders: [new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
headers,
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 15_000,
exportTimeoutMillis: 10_000,
})],
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter({
url: `${otlpEndpoint}/v1/logs`,
headers,
}))],
}
: {}),
instrumentations: [
+1
View File
@@ -18,6 +18,7 @@
"dependencies": {
"@dotenvx/dotenvx": "catalog:",
"@electric-sql/pglite": "catalog:",
"@electric-sql/pglite-pgvector": "catalog:",
"@guiiai/logg": "catalog:",
"@hono/node-server": "catalog:",
"@hono/node-ws": "catalog:",
+47 -47
View File
@@ -45,47 +45,27 @@ function optionalNumberFromString(defaultValue: number, envKey: string, minimum:
}
const EnvSchema = object({
HOST: optional(string(), '0.0.0.0'),
PORT: optionalIntegerFromString(3000, 'PORT', 1),
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
AUTH_SERVER_URL: optional(string(), 'http://localhost:3000'),
AUTH_SERVER_INTERNAL_URL: optional(string()),
// Canonical user-facing web app origin. Used as the Stripe redirect base
// (success_url / cancel_url / portal return_url) when a request has no trusted
// browser origin — notably the Electron desktop renderer, which loads from
// file:// and sends no usable web origin. Web/mobile requests keep returning to
// their own origin; only origin-less clients fall back to this.
WEB_APP_URL: optional(string(), 'https://airi.moeru.ai'),
// Comma-separated exact origins (e.g. Capacitor dev server `https://10.x:5273`).
// Prefer this over broad private-IP regex heuristics in production-like configs.
ADDITIONAL_TRUSTED_ORIGINS: optional(
AdditionalTrustedOriginsSchema,
'',
),
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
AUTH_SERVER_INTERNAL_URL: optional(string()),
AUTH_SERVER_URL: optional(string(), 'http://localhost:3000'),
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')),
// Testing-only bearer token bypass. Keep unset in production. When set,
// Authorization: Bearer $TEST_AUTH_TOKEN resolves to the virtual user below
// through resolveRequestAuth without creating an Auth session row.
TEST_AUTH_TOKEN: optional(string(), ''),
TEST_AUTH_USER_ID: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_ID must not be empty when set')), 'test-user'),
TEST_AUTH_USER_EMAIL: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_EMAIL must not be empty when set')), 'test@example.com'),
TEST_AUTH_USER_NAME: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_NAME must not be empty when set')), 'Test User'),
DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1),
STRIPE_SECRET_KEY: optional(string()),
STRIPE_WEBHOOK_SECRET: optional(string()),
DB_POOL_IDLE_TIMEOUT_MS: optionalIntegerFromString(30000, 'DB_POOL_IDLE_TIMEOUT_MS', 1),
// LLM/TTS gateway is fully internalised by the in-process router; provider
// baseURLs live per-upstream inside LLM_ROUTER_CONFIG, and the default chat /
// tts model aliases moved to configKV (DEFAULT_CHAT_MODEL / DEFAULT_TTS_MODEL)
// so they're hot-swappable via Pub/Sub invalidation. No env entries needed
// here.
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1),
// Database pool
DB_POOL_MAX: optionalIntegerFromString(20, 'DB_POOL_MAX', 1),
HOST: optional(string(), '0.0.0.0'),
// Envelope-encryption master key for in-process LLM/TTS router (KTD-5).
// Stored as base64-encoded 32 random bytes. Validator decodes + asserts the
// 32-byte length at parse time so a misconfigured key fails the deploy
@@ -107,27 +87,47 @@ const EnvSchema = object({
transform(b64 => Buffer.from(b64, 'base64')),
check(buf => buf.length === 32, 'LLM_ROUTER_MASTER_KEY_PREVIOUS must decode to exactly 32 bytes when set'),
)),
OTEL_DEBUG: optional(string()),
// Database pool
DB_POOL_MAX: optionalIntegerFromString(20, 'DB_POOL_MAX', 1),
DB_POOL_IDLE_TIMEOUT_MS: optionalIntegerFromString(30000, 'DB_POOL_IDLE_TIMEOUT_MS', 1),
DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1),
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1),
// PostHog product-event forwarding for server-confirmed funnel facts.
// Defaults to the shared AIRI project key (same browser-safe phc_* key the
// client surfaces embed in posthog.config.ts), so forwarding is on out of
// the box. Set to an empty string to disable server-side product analytics.
POSTHOG_PROJECT_KEY: optional(string(), 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9'), // cspell:disable-line
POSTHOG_API_HOST: optional(string(), 'https://t.airi.build'),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),
OTEL_TRACES_SAMPLING_RATIO: optionalNumberFromString(1, 'OTEL_TRACES_SAMPLING_RATIO', 0, 1),
OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()),
OTEL_EXPORTER_OTLP_HEADERS: optional(string()),
OTEL_DEBUG: optional(string()),
// LLM/TTS gateway is fully internalised by the in-process router; provider
// baseURLs live per-upstream inside LLM_ROUTER_CONFIG, and the default chat /
// tts model aliases moved to configKV (DEFAULT_CHAT_MODEL / DEFAULT_TTS_MODEL)
// so they're hot-swappable via Pub/Sub invalidation. No env entries needed
// here.
OTEL_SERVICE_NAME: optional(string(), 'server'),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_TRACES_SAMPLING_RATIO: optionalNumberFromString(1, 'OTEL_TRACES_SAMPLING_RATIO', 0, 1),
PORT: optionalIntegerFromString(3000, 'PORT', 1),
POSTHOG_API_HOST: optional(string(), 'https://t.airi.build'),
// PostHog product-event forwarding for server-confirmed funnel facts.
// Defaults to the shared AIRI project key (same browser-safe phc_* key the
// client surfaces embed in stage-shared/analytics/posthog), so forwarding is on out of
// the box. Set to an empty string to disable server-side product analytics.
POSTHOG_PROJECT_KEY: optional(string(), 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9'), // cspell:disable-line
REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')),
STRIPE_SECRET_KEY: optional(string()),
STRIPE_WEBHOOK_SECRET: optional(string()),
// Testing-only bearer token bypass. Keep unset in production. When set,
// Authorization: Bearer $TEST_AUTH_TOKEN resolves to the virtual user below
// through resolveRequestAuth without creating an Auth session row.
TEST_AUTH_TOKEN: optional(string(), ''),
TEST_AUTH_USER_EMAIL: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_EMAIL must not be empty when set')), 'test@example.com'),
TEST_AUTH_USER_ID: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_ID must not be empty when set')), 'test-user'),
TEST_AUTH_USER_NAME: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_NAME must not be empty when set')), 'Test User'),
// Canonical user-facing web app origin. Used as the Stripe redirect base
// (success_url / cancel_url / portal return_url) when a request has no trusted
// browser origin — notably the Electron desktop renderer, which loads from
// file:// and sends no usable web origin. Web/mobile requests keep returning to
// their own origin; only origin-less clients fall back to this.
WEB_APP_URL: optional(string(), 'https://airi.moeru.ai'),
})
export type Env = InferOutput<typeof EnvSchema>
+22
View File
@@ -0,0 +1,22 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { mockDB } from './mock-db'
describe('mockDB', () => {
it('registers the pgvector extension', async () => {
const db = await mockDB({})
// ROOT CAUSE:
//
// PGlite 0.5 moved pgvector from the main package to a separate package.
// The old subpath import prevents this test database from loading.
//
// The helper now registers the separate extension before it creates the schema.
const result = await db.execute<{ distance: number }>(sql`
SELECT '[1, 2, 3]'::vector <-> '[1, 2, 4]'::vector AS distance
`)
expect(result.rows).toEqual([{ distance: 1 }])
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Database } from './db'
import { PGlite } from '@electric-sql/pglite'
import { vector } from '@electric-sql/pglite/vector'
import { vector } from '@electric-sql/pglite-pgvector'
import { sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/pglite'
import { vi } from 'vitest'
+19 -17
View File
@@ -51,27 +51,12 @@ else {
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: env.OTEL_SERVICE_NAME || 'auth-server',
[ATTR_SERVICE_VERSION]: env.npm_package_version || '0.0.0',
'service.namespace': env.OTEL_SERVICE_NAMESPACE || 'airi',
'service.instance.id': instanceId,
'deployment.environment': env.NODE_ENV || 'development',
'service.instance.id': instanceId,
'service.namespace': env.OTEL_SERVICE_NAMESPACE || 'airi',
})
const sdk = new NodeSDK({
resource,
sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(samplingRatio) }),
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
headers,
}))],
metricReaders: [new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${otlpEndpoint}/v1/metrics`, headers }),
exportIntervalMillis: 15_000,
exportTimeoutMillis: 10_000,
})],
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter({
url: `${otlpEndpoint}/v1/logs`,
headers,
}))],
instrumentations: [
new HttpInstrumentation({ disableIncomingRequestInstrumentation: true }),
new PgInstrumentation({ enhancedDatabaseReporting: true }),
@@ -79,6 +64,23 @@ else {
new RuntimeNodeInstrumentation(),
new UndiciInstrumentation(),
],
logRecordProcessors: [new BatchLogRecordProcessor({
exporter: new OTLPLogExporter({
headers,
url: `${otlpEndpoint}/v1/logs`,
}),
})],
metricReaders: [new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ headers, url: `${otlpEndpoint}/v1/metrics` }),
exportIntervalMillis: 15_000,
exportTimeoutMillis: 10_000,
})],
resource,
sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(samplingRatio) }),
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({
headers,
url: `${otlpEndpoint}/v1/traces`,
}))],
})
sdk.start()
+1
View File
@@ -54,6 +54,7 @@
"@electric-sql/pglite": "catalog:",
"@types/pg": "catalog:",
"drizzle-kit": "catalog:",
"kysely": "catalog:",
"typescript": "catalog:"
}
}