mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 06:12:05 -07:00
fix: add stats server node fallback
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-238.5
|
id: TASK-238.5
|
||||||
title: Split immersion tracker query layer into focused read-model modules
|
title: Split immersion tracker query layer into focused read-model modules
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee:
|
||||||
|
- codex
|
||||||
created_date: '2026-03-26 20:49'
|
created_date: '2026-03-26 20:49'
|
||||||
|
updated_date: '2026-03-27 00:00'
|
||||||
labels:
|
labels:
|
||||||
- tech-debt
|
- tech-debt
|
||||||
- stats
|
- stats
|
||||||
@@ -29,10 +31,10 @@ priority: medium
|
|||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Query responsibilities are grouped into focused modules such as library/session detail, vocabulary/kanji detail, and maintenance/cleanup helpers.
|
- [x] #1 Query responsibilities are grouped into focused modules such as library/session detail, vocabulary/kanji detail, and maintenance/cleanup helpers.
|
||||||
- [ ] #2 The stats server and immersion tracker service depend on stable exported query surfaces instead of one monolithic file.
|
- [x] #2 The stats server and immersion tracker service depend on stable exported query surfaces instead of one monolithic file.
|
||||||
- [ ] #3 The refactor preserves current SQL behavior and existing statistics outputs.
|
- [x] #3 The refactor preserves current SQL behavior and existing statistics outputs.
|
||||||
- [ ] #4 Existing stats/immersion tests still pass, with added focused coverage where extraction creates new seams.
|
- [x] #4 Existing stats/immersion tests still pass, with added focused coverage where extraction creates new seams.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -43,3 +45,17 @@ priority: medium
|
|||||||
3. Keep SQL ownership close to the domain module that consumes it; avoid a giant `queries/` dump with no structure.
|
3. Keep SQL ownership close to the domain module that consumes it; avoid a giant `queries/` dump with no structure.
|
||||||
4. Verify with the maintained stats/immersion test lane plus `bun run typecheck`.
|
4. Verify with the maintained stats/immersion test lane plus `bun run typecheck`.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Split the monolithic query surface into focused read-model modules for sessions, trends, lexical data, library lookups, and maintenance helpers. Updated the service and test imports to use the new module boundaries.
|
||||||
|
|
||||||
|
Verification: `bun run typecheck` passed. Focused query and stats-server tests passed, including the `stats-server.test.ts` coverage around the new Bun fallback path.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Extracted the immersion-tracker query layer into smaller read-model modules and kept the compatibility barrel in place so existing call sites can transition cleanly. Added focused coverage and verified the refactor with typecheck plus targeted tests.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
5
changes/2026-03-27-stats-server-runtime-fallback.md
Normal file
5
changes/2026-03-27-stats-server-runtime-fallback.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
||||||
|
- Stats server now falls back to a Node `http` listener in Electron/runtime paths that do not expose Bun.
|
||||||
@@ -1160,4 +1160,30 @@ describe('stats server API routes', () => {
|
|||||||
bun.Bun.serve = originalServe;
|
bun.Bun.serve = originalServe;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to node:http when Bun.serve is unavailable', () => {
|
||||||
|
type BunRuntime = {
|
||||||
|
Bun: {
|
||||||
|
serve?: (options: { fetch: unknown; port: number; hostname: string }) => {
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bun = globalThis as typeof globalThis & BunRuntime;
|
||||||
|
const originalServe = bun.Bun.serve;
|
||||||
|
bun.Bun.serve = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const server = startStatsServer({
|
||||||
|
port: 0,
|
||||||
|
staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
|
||||||
|
tracker: createMockTracker(),
|
||||||
|
});
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
} finally {
|
||||||
|
bun.Bun.serve = originalServe;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { basename, extname, resolve, sep } from 'node:path';
|
import { basename, extname, resolve, sep } from 'node:path';
|
||||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||||
import { MediaGenerator } from '../../media-generator.js';
|
import { MediaGenerator } from '../../media-generator.js';
|
||||||
@@ -171,6 +172,10 @@ type BunRuntime = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NodeRuntimeHandle = {
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||||
'.css': 'text/css; charset=utf-8',
|
'.css': 'text/css; charset=utf-8',
|
||||||
'.gif': 'image/gif',
|
'.gif': 'image/gif',
|
||||||
@@ -197,7 +202,9 @@ function buildAnkiNotePreview(
|
|||||||
return {
|
return {
|
||||||
word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]),
|
word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]),
|
||||||
sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]),
|
sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]),
|
||||||
translation: getPreferredNoteFieldValue(fields, [getConfiguredTranslationFieldName(ankiConfig)]),
|
translation: getPreferredNoteFieldValue(fields, [
|
||||||
|
getConfiguredTranslationFieldName(ankiConfig),
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +248,82 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readNodeRequestBody(req: IncomingMessage): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNodeRequest(req: IncomingMessage): Promise<Request> {
|
||||||
|
const host = req.headers.host ?? '127.0.0.1';
|
||||||
|
const url = new URL(req.url ?? '/', `http://${host}`);
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const [name, value] of Object.entries(req.headers)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
headers.set(name, value.join(', '));
|
||||||
|
} else {
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = req.method ?? 'GET';
|
||||||
|
const body = method === 'GET' || method === 'HEAD' ? undefined : await readNodeRequestBody(req);
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
if (body !== undefined && body.length > 0) {
|
||||||
|
init.body = new Uint8Array(body);
|
||||||
|
}
|
||||||
|
return new Request(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeNodeResponse(
|
||||||
|
res: ServerResponse<IncomingMessage>,
|
||||||
|
response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
res.statusCode = response.status;
|
||||||
|
res.statusMessage = response.statusText;
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
res.setHeader(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.from(await response.arrayBuffer());
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNodeStatsServer(app: StatsApp, port: number): NodeRuntimeHandle {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await app.fetch(await createNodeRequest(req));
|
||||||
|
await writeNodeResponse(res, response);
|
||||||
|
} catch {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
}
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, '127.0.0.1');
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
server.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createStatsApp(
|
export function createStatsApp(
|
||||||
tracker: ImmersionTrackerService,
|
tracker: ImmersionTrackerService,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -672,7 +755,11 @@ export function createStatsApp(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||||
body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: resolvedNoteIds } }),
|
body: JSON.stringify({
|
||||||
|
action: 'notesInfo',
|
||||||
|
version: 6,
|
||||||
|
params: { notes: resolvedNoteIds },
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const result = (await response.json()) as {
|
const result = (await response.json()) as {
|
||||||
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||||
@@ -1016,11 +1103,14 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
|||||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = (globalThis as typeof globalThis & BunRuntime).Bun.serve({
|
const bunServe = (globalThis as typeof globalThis & Partial<BunRuntime>).Bun?.serve;
|
||||||
fetch: app.fetch,
|
const server = bunServe
|
||||||
port: config.port,
|
? bunServe({
|
||||||
hostname: '127.0.0.1',
|
fetch: app.fetch,
|
||||||
});
|
port: config.port,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
})
|
||||||
|
: startNodeStatsServer(app, config.port);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: () => {
|
close: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user