sh3-server 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +9 -0
  2. package/app/assets/SH3-BG5NVpSD.png +0 -0
  3. package/app/assets/icons-CnAqUqbR.svg +1126 -0
  4. package/app/assets/index-B1mKDfA-.css +1 -0
  5. package/app/assets/index-C7fUtJvb.js +9 -0
  6. package/app/assets/tauri-backend-B3LR3-lo.js +1 -0
  7. package/app/index.html +13 -0
  8. package/dist/auth.d.ts +34 -0
  9. package/dist/auth.js +107 -0
  10. package/dist/cli.d.ts +9 -0
  11. package/dist/cli.js +30 -0
  12. package/dist/index.d.ts +35 -0
  13. package/dist/index.js +228 -0
  14. package/dist/keys.d.ts +33 -0
  15. package/dist/keys.js +68 -0
  16. package/dist/packages.d.ts +38 -0
  17. package/dist/packages.js +256 -0
  18. package/dist/routes/admin.d.ts +10 -0
  19. package/dist/routes/admin.js +126 -0
  20. package/dist/routes/auth.d.ts +13 -0
  21. package/dist/routes/auth.js +97 -0
  22. package/dist/routes/boot.d.ts +11 -0
  23. package/dist/routes/boot.js +56 -0
  24. package/dist/routes/docs.d.ts +14 -0
  25. package/dist/routes/docs.js +133 -0
  26. package/dist/routes/env-state.d.ts +8 -0
  27. package/dist/routes/env-state.js +62 -0
  28. package/dist/sessions.d.ts +26 -0
  29. package/dist/sessions.js +59 -0
  30. package/dist/settings.d.ts +22 -0
  31. package/dist/settings.js +63 -0
  32. package/dist/shard-router.d.ts +68 -0
  33. package/dist/shard-router.js +145 -0
  34. package/dist/shell-shard/history-store.d.ts +11 -0
  35. package/dist/shell-shard/history-store.js +65 -0
  36. package/dist/shell-shard/index.d.ts +14 -0
  37. package/dist/shell-shard/index.js +51 -0
  38. package/dist/shell-shard/runner.d.ts +22 -0
  39. package/dist/shell-shard/runner.js +84 -0
  40. package/dist/shell-shard/session-manager.d.ts +56 -0
  41. package/dist/shell-shard/session-manager.js +161 -0
  42. package/dist/shell-shard/tokenize.d.ts +1 -0
  43. package/dist/shell-shard/tokenize.js +68 -0
  44. package/dist/shell-shard/ws.d.ts +2 -0
  45. package/dist/shell-shard/ws.js +78 -0
  46. package/dist/users.d.ts +44 -0
  47. package/dist/users.js +113 -0
  48. package/package.json +49 -0
  49. package/static/404.html +53 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Document backend API routes.
3
+ *
4
+ * Maps the DocumentBackend interface to HTTP endpoints backed by the
5
+ * local filesystem. Files are stored at {dataDir}/docs/{tenant}/{shard}/{path}.
6
+ *
7
+ * GET /api/docs/:tenant/:shard → list
8
+ * GET /api/docs/:tenant/:shard/*path → read
9
+ * HEAD /api/docs/:tenant/:shard/*path → exists
10
+ * PUT /api/docs/:tenant/:shard/*path → write (auth required)
11
+ * DELETE /api/docs/:tenant/:shard/*path → delete (auth required)
12
+ */
13
+ import { Hono } from 'hono';
14
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, readdirSync, } from 'node:fs';
15
+ import { join, dirname, relative } from 'node:path';
16
+ export function createDocsRouter(dataDir) {
17
+ const router = new Hono();
18
+ const docsDir = join(dataDir, 'docs');
19
+ function resolvePath(tenant, shard, filePath) {
20
+ // Prevent path traversal
21
+ const resolved = join(docsDir, tenant, shard, filePath);
22
+ if (!resolved.startsWith(join(docsDir, tenant, shard))) {
23
+ throw new Error('Path traversal detected');
24
+ }
25
+ return resolved;
26
+ }
27
+ function collectFiles(dir, base) {
28
+ const results = [];
29
+ if (!existsSync(dir))
30
+ return results;
31
+ const entries = readdirSync(dir, { withFileTypes: true });
32
+ for (const entry of entries) {
33
+ const full = join(dir, entry.name);
34
+ if (entry.isDirectory()) {
35
+ results.push(...collectFiles(full, base));
36
+ }
37
+ else {
38
+ const stat = statSync(full);
39
+ results.push({
40
+ path: relative(base, full).replace(/\\/g, '/'),
41
+ size: stat.size,
42
+ lastModified: stat.mtimeMs,
43
+ });
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+ // List documents for a tenant/shard
49
+ router.get('/:tenant/:shard', (c) => {
50
+ const { tenant, shard } = c.req.param();
51
+ const dir = join(docsDir, tenant, shard);
52
+ const files = collectFiles(dir, dir);
53
+ return c.json(files);
54
+ });
55
+ // Read a document
56
+ router.get('/:tenant/:shard/*', (c) => {
57
+ const { tenant, shard } = c.req.param();
58
+ const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
59
+ if (!filePath)
60
+ return c.json({ error: 'Missing file path' }, 400);
61
+ let resolved;
62
+ try {
63
+ resolved = resolvePath(tenant, shard, filePath);
64
+ }
65
+ catch {
66
+ return c.json({ error: 'Invalid path' }, 400);
67
+ }
68
+ if (!existsSync(resolved)) {
69
+ return c.notFound();
70
+ }
71
+ const content = readFileSync(resolved);
72
+ // Detect binary vs text heuristically: if the file can be decoded as
73
+ // UTF-8 without replacement characters, treat as text.
74
+ const text = new TextDecoder('utf-8', { fatal: true });
75
+ try {
76
+ const str = text.decode(content);
77
+ return c.text(str);
78
+ }
79
+ catch {
80
+ return new Response(content, {
81
+ headers: { 'Content-Type': 'application/octet-stream' },
82
+ });
83
+ }
84
+ });
85
+ // Check existence via HEAD request
86
+ router.on('HEAD', '/:tenant/:shard/*', (c) => {
87
+ const { tenant, shard } = c.req.param();
88
+ const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
89
+ let resolved;
90
+ try {
91
+ resolved = resolvePath(tenant, shard, filePath);
92
+ }
93
+ catch {
94
+ return new Response(null, { status: 400 });
95
+ }
96
+ return new Response(null, { status: existsSync(resolved) ? 200 : 404 });
97
+ });
98
+ // Write a document (auth required via middleware)
99
+ router.put('/:tenant/:shard/*', async (c) => {
100
+ const { tenant, shard } = c.req.param();
101
+ const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
102
+ if (!filePath)
103
+ return c.json({ error: 'Missing file path' }, 400);
104
+ let resolved;
105
+ try {
106
+ resolved = resolvePath(tenant, shard, filePath);
107
+ }
108
+ catch {
109
+ return c.json({ error: 'Invalid path' }, 400);
110
+ }
111
+ mkdirSync(dirname(resolved), { recursive: true });
112
+ const body = await c.req.arrayBuffer();
113
+ writeFileSync(resolved, Buffer.from(body));
114
+ return c.json({ ok: true });
115
+ });
116
+ // Delete a document (auth required via middleware)
117
+ router.delete('/:tenant/:shard/*', (c) => {
118
+ const { tenant, shard } = c.req.param();
119
+ const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
120
+ let resolved;
121
+ try {
122
+ resolved = resolvePath(tenant, shard, filePath);
123
+ }
124
+ catch {
125
+ return c.json({ error: 'Invalid path' }, 400);
126
+ }
127
+ if (existsSync(resolved)) {
128
+ unlinkSync(resolved);
129
+ }
130
+ return c.json({ ok: true });
131
+ });
132
+ return router;
133
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Environment state routes — generic per-shard JSON storage.
3
+ *
4
+ * Server stores env state as files under {dataDir}/env-state/{shardId}.json.
5
+ * GET is public (read by all clients). PUT is admin-gated (blanket /api/* auth).
6
+ */
7
+ import { Hono } from 'hono';
8
+ export declare function createEnvStateRouter(dataDir: string): Hono;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Environment state routes — generic per-shard JSON storage.
3
+ *
4
+ * Server stores env state as files under {dataDir}/env-state/{shardId}.json.
5
+ * GET is public (read by all clients). PUT is admin-gated (blanket /api/* auth).
6
+ */
7
+ import { Hono } from 'hono';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ /** Validate shard ID — alphanumeric, hyphens, underscores, colons, @ (scoped packages). */
11
+ function isValidShardId(id) {
12
+ return /^[a-zA-Z0-9_:@-]+$/.test(id) && id.length <= 128;
13
+ }
14
+ export function createEnvStateRouter(dataDir) {
15
+ const envDir = join(dataDir, 'env-state');
16
+ const router = new Hono();
17
+ router.get('/:shardId', (c) => {
18
+ const shardId = c.req.param('shardId');
19
+ if (!isValidShardId(shardId)) {
20
+ return c.json({ error: 'Invalid shard ID' }, 400);
21
+ }
22
+ const resolved = join(envDir, `${shardId}.json`);
23
+ if (!resolved.startsWith(envDir)) {
24
+ return c.json({ error: 'Invalid shard ID' }, 400);
25
+ }
26
+ if (!existsSync(resolved)) {
27
+ return c.json({});
28
+ }
29
+ try {
30
+ const raw = readFileSync(resolved, 'utf-8');
31
+ return c.json(JSON.parse(raw));
32
+ }
33
+ catch (err) {
34
+ console.warn(`[sh3] Corrupt env state for "${shardId}":`, err);
35
+ return c.json({});
36
+ }
37
+ });
38
+ router.put('/:shardId', async (c) => {
39
+ const shardId = c.req.param('shardId');
40
+ if (!isValidShardId(shardId)) {
41
+ return c.json({ error: 'Invalid shard ID' }, 400);
42
+ }
43
+ let body;
44
+ try {
45
+ body = await c.req.json();
46
+ }
47
+ catch {
48
+ return c.json({ error: 'Invalid JSON body' }, 400);
49
+ }
50
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
51
+ return c.json({ error: 'Body must be a JSON object' }, 400);
52
+ }
53
+ const resolved = join(envDir, `${shardId}.json`);
54
+ if (!resolved.startsWith(envDir)) {
55
+ return c.json({ error: 'Invalid shard ID' }, 400);
56
+ }
57
+ mkdirSync(envDir, { recursive: true });
58
+ writeFileSync(resolved, JSON.stringify(body, null, 2));
59
+ return c.json({ ok: true });
60
+ });
61
+ return router;
62
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * In-memory session store. Sessions don't survive server restart.
3
+ * Token format: sh3s_<random hex>
4
+ */
5
+ export interface Session {
6
+ token: string;
7
+ userId: string;
8
+ role: 'admin' | 'user';
9
+ createdAt: number;
10
+ expiresAt: number;
11
+ }
12
+ export declare class SessionStore {
13
+ #private;
14
+ /** @param defaultTTLHours — default session lifetime in hours. */
15
+ constructor(defaultTTLHours?: number);
16
+ /** Create a session for a user. Returns the new session. */
17
+ create(userId: string, role: 'admin' | 'user', ttlHours?: number): Session;
18
+ /** Validate a token. Returns the session if valid and not expired, else null. */
19
+ validate(token: string): Session | null;
20
+ /** Revoke a session by token. */
21
+ revoke(token: string): boolean;
22
+ /** Purge all expired sessions. Call periodically. */
23
+ cleanup(): number;
24
+ /** Update the default TTL (called when settings change). */
25
+ setDefaultTTL(hours: number): void;
26
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * In-memory session store. Sessions don't survive server restart.
3
+ * Token format: sh3s_<random hex>
4
+ */
5
+ import { randomBytes } from 'node:crypto';
6
+ export class SessionStore {
7
+ #sessions = new Map();
8
+ #defaultTTL;
9
+ /** @param defaultTTLHours — default session lifetime in hours. */
10
+ constructor(defaultTTLHours = 24) {
11
+ this.#defaultTTL = defaultTTLHours * 3600 * 1000;
12
+ }
13
+ /** Create a session for a user. Returns the new session. */
14
+ create(userId, role, ttlHours) {
15
+ const token = `sh3s_${randomBytes(32).toString('hex')}`;
16
+ const now = Date.now();
17
+ const ttl = ttlHours ? ttlHours * 3600 * 1000 : this.#defaultTTL;
18
+ const session = {
19
+ token,
20
+ userId,
21
+ role,
22
+ createdAt: now,
23
+ expiresAt: now + ttl,
24
+ };
25
+ this.#sessions.set(token, session);
26
+ return session;
27
+ }
28
+ /** Validate a token. Returns the session if valid and not expired, else null. */
29
+ validate(token) {
30
+ const session = this.#sessions.get(token);
31
+ if (!session)
32
+ return null;
33
+ if (Date.now() > session.expiresAt) {
34
+ this.#sessions.delete(token);
35
+ return null;
36
+ }
37
+ return session;
38
+ }
39
+ /** Revoke a session by token. */
40
+ revoke(token) {
41
+ return this.#sessions.delete(token);
42
+ }
43
+ /** Purge all expired sessions. Call periodically. */
44
+ cleanup() {
45
+ const now = Date.now();
46
+ let removed = 0;
47
+ for (const [token, session] of this.#sessions) {
48
+ if (now > session.expiresAt) {
49
+ this.#sessions.delete(token);
50
+ removed++;
51
+ }
52
+ }
53
+ return removed;
54
+ }
55
+ /** Update the default TTL (called when settings change). */
56
+ setDefaultTTL(hours) {
57
+ this.#defaultTTL = hours * 3600 * 1000;
58
+ }
59
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Global settings — persisted to {dataDir}/settings.json.
3
+ * Provides typed access with defaults for missing fields.
4
+ */
5
+ export interface GlobalSettings {
6
+ auth: {
7
+ required: boolean;
8
+ guestAllowed: boolean;
9
+ sessionTTL: number;
10
+ selfRegistration: boolean;
11
+ };
12
+ }
13
+ export declare class SettingsStore {
14
+ #private;
15
+ constructor(dataDir: string);
16
+ /** Get current settings. */
17
+ get(): GlobalSettings;
18
+ /** Patch settings. Only provided fields are updated. */
19
+ update(patch: {
20
+ auth?: Partial<GlobalSettings['auth']>;
21
+ }): GlobalSettings;
22
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Global settings — persisted to {dataDir}/settings.json.
3
+ * Provides typed access with defaults for missing fields.
4
+ */
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+ const DEFAULTS = {
8
+ auth: {
9
+ required: true,
10
+ guestAllowed: false,
11
+ sessionTTL: 24,
12
+ selfRegistration: false,
13
+ },
14
+ };
15
+ export class SettingsStore {
16
+ #path;
17
+ #settings;
18
+ constructor(dataDir) {
19
+ this.#path = `${dataDir}/settings.json`;
20
+ this.#settings = this.#load();
21
+ }
22
+ #load() {
23
+ if (!existsSync(this.#path))
24
+ return structuredClone(DEFAULTS);
25
+ try {
26
+ const raw = JSON.parse(readFileSync(this.#path, 'utf-8'));
27
+ return {
28
+ auth: {
29
+ required: raw.auth?.required ?? DEFAULTS.auth.required,
30
+ guestAllowed: raw.auth?.guestAllowed ?? DEFAULTS.auth.guestAllowed,
31
+ sessionTTL: raw.auth?.sessionTTL ?? DEFAULTS.auth.sessionTTL,
32
+ selfRegistration: raw.auth?.selfRegistration ?? DEFAULTS.auth.selfRegistration,
33
+ },
34
+ };
35
+ }
36
+ catch {
37
+ return structuredClone(DEFAULTS);
38
+ }
39
+ }
40
+ #save() {
41
+ mkdirSync(dirname(this.#path), { recursive: true });
42
+ writeFileSync(this.#path, JSON.stringify(this.#settings, null, 2));
43
+ }
44
+ /** Get current settings. */
45
+ get() {
46
+ return structuredClone(this.#settings);
47
+ }
48
+ /** Patch settings. Only provided fields are updated. */
49
+ update(patch) {
50
+ if (patch.auth) {
51
+ if (patch.auth.required !== undefined)
52
+ this.#settings.auth.required = patch.auth.required;
53
+ if (patch.auth.guestAllowed !== undefined)
54
+ this.#settings.auth.guestAllowed = patch.auth.guestAllowed;
55
+ if (patch.auth.sessionTTL !== undefined)
56
+ this.#settings.auth.sessionTTL = patch.auth.sessionTTL;
57
+ if (patch.auth.selfRegistration !== undefined)
58
+ this.#settings.auth.selfRegistration = patch.auth.selfRegistration;
59
+ }
60
+ this.#save();
61
+ return this.get();
62
+ }
63
+ }
@@ -0,0 +1,68 @@
1
+ import { Hono } from 'hono';
2
+ import type { MiddlewareHandler } from 'hono';
3
+ import type { KeyStore } from './keys.js';
4
+ import type { SettingsStore } from './settings.js';
5
+ export interface MountContext {
6
+ pkgDir: string;
7
+ keys: KeyStore;
8
+ settings: SettingsStore;
9
+ /**
10
+ * Register a WebSocket upgrade handler on a path under this shard's
11
+ * route prefix. The returned value is a Hono middleware handler that
12
+ * can be passed directly as the handler argument to `router.get(path, ...)`.
13
+ *
14
+ * The `ws` argument passed to `onConnect` is the server-side WebSocket
15
+ * from `@hono/node-ws` (which wraps the `ws` package). `@hono/node-ws`
16
+ * does not re-export a type name for it and `@types/ws` is not a
17
+ * dependency, so we use `any` per the plan's explicit fallback rule.
18
+ * The returned handler is likewise typed as `any` — its concrete type
19
+ * is Hono's internal `MiddlewareHandler<..., { outputFormat: "ws" }>`,
20
+ * which would leak Hono ws internals into every shard that touches it.
21
+ *
22
+ * The `c` argument is the Hono `Context` for the upgrade request.
23
+ * Shards can read `c.get('session')` to route the connection to the
24
+ * correct per-user state (e.g. `manager.getOrCreate(session.userId)`).
25
+ *
26
+ * @param onConnect Called with the raw WebSocket and request Context
27
+ * when a client connects. Attach listeners in the body;
28
+ * return nothing.
29
+ */
30
+ wsRegister(onConnect: (ws: any, c: any) => void): any;
31
+ }
32
+ /** Middleware that requires admin session OR valid API key. */
33
+ export declare function adminOnly(keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
34
+ /**
35
+ * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
36
+ * and delegates requests from a single wildcard route.
37
+ */
38
+ export declare class ShardRouter {
39
+ private shards;
40
+ /**
41
+ * Import a server.js bundle and mount its routes.
42
+ * Throws on import failure — caller is responsible for rollback.
43
+ */
44
+ mount(shardId: string, serverJsPath: string, ctx: MountContext): Promise<void>;
45
+ /**
46
+ * Mount a shard server module that is already imported (e.g. a framework
47
+ * built-in bundled with sh3-server, not discovered from the package store).
48
+ * Skips the `import()` call and goes straight to `routes()` wiring.
49
+ */
50
+ mountStatic(shardId: string, mod: {
51
+ id: string;
52
+ routes: (router: Hono, ctx: any) => void | Promise<void>;
53
+ }, ctx: MountContext): Promise<void>;
54
+ /** Remove a shard's routes. */
55
+ unmount(shardId: string): boolean;
56
+ /**
57
+ * Hono handler for the wildcard route.
58
+ * Looks up the shard sub-app and delegates with path stripping.
59
+ */
60
+ handler(): (c: any, next: () => Promise<void>) => Promise<Response | void>;
61
+ /** List all routes across all mounted shards (for /api/routes). */
62
+ listRoutes(): Array<{
63
+ method: string;
64
+ path: string;
65
+ }>;
66
+ /** Check if a shard is currently mounted. */
67
+ has(shardId: string): boolean;
68
+ }
@@ -0,0 +1,145 @@
1
+ // packages/sh3-server/src/shard-router.ts
2
+ import { Hono } from 'hono';
3
+ import { mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ /** Middleware that requires admin session OR valid API key. */
7
+ export function adminOnly(keys, settings) {
8
+ return async (c, next) => {
9
+ // When auth is disabled (--no-auth), skip admin checks entirely
10
+ if (!settings.get().auth.required) {
11
+ return next();
12
+ }
13
+ // Session-based admin — forwarded from upstream sessionAuth via env
14
+ const session = c.get('session') ?? c.env?.session;
15
+ if (session?.role === 'admin') {
16
+ return next();
17
+ }
18
+ // Fallback: API key (for external tools / CLI)
19
+ const authHeader = c.req.header('Authorization');
20
+ if (authHeader?.startsWith('Bearer sh3_')) {
21
+ const token = authHeader.slice(7);
22
+ if (keys.validate(token)) {
23
+ return next();
24
+ }
25
+ }
26
+ return c.json({ error: 'Admin privileges required' }, 403);
27
+ };
28
+ }
29
+ /**
30
+ * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
31
+ * and delegates requests from a single wildcard route.
32
+ */
33
+ export class ShardRouter {
34
+ shards = new Map();
35
+ /**
36
+ * Import a server.js bundle and mount its routes.
37
+ * Throws on import failure — caller is responsible for rollback.
38
+ */
39
+ async mount(shardId, serverJsPath, ctx) {
40
+ const fileUrl = pathToFileURL(serverJsPath).href + `?t=${Date.now()}`;
41
+ const mod = await import(fileUrl);
42
+ const shard = mod.default ?? mod;
43
+ if (typeof shard.id !== 'string' || typeof shard.routes !== 'function') {
44
+ throw new Error(`${shardId}/server.js invalid export shape — expected { id, routes }`);
45
+ }
46
+ const shardDataDir = join(ctx.pkgDir, 'data');
47
+ const router = new Hono();
48
+ const shardCtx = {
49
+ shardId: shard.id,
50
+ dataDir: shardDataDir,
51
+ adminOnly: adminOnly(ctx.keys, ctx.settings),
52
+ wsRegister: ctx.wsRegister,
53
+ };
54
+ await shard.routes(router, shardCtx);
55
+ // Create data dir only after routes() succeeds — so a failure
56
+ // doesn't leave behind a dir that prevents install rollback cleanup.
57
+ mkdirSync(shardDataDir, { recursive: true });
58
+ this.shards.set(shardId, router);
59
+ console.log(`[sh3] ${shardId} — server routes mounted at /api/${shardId}/`);
60
+ }
61
+ /**
62
+ * Mount a shard server module that is already imported (e.g. a framework
63
+ * built-in bundled with sh3-server, not discovered from the package store).
64
+ * Skips the `import()` call and goes straight to `routes()` wiring.
65
+ */
66
+ async mountStatic(shardId, mod, ctx) {
67
+ if (typeof mod.id !== 'string' || typeof mod.routes !== 'function') {
68
+ throw new Error(`${shardId} static mount — expected { id, routes }, got ${typeof mod}`);
69
+ }
70
+ const shardDataDir = join(ctx.pkgDir, 'data');
71
+ const router = new Hono();
72
+ const shardCtx = {
73
+ shardId: mod.id,
74
+ dataDir: shardDataDir,
75
+ adminOnly: adminOnly(ctx.keys, ctx.settings),
76
+ wsRegister: ctx.wsRegister,
77
+ };
78
+ await mod.routes(router, shardCtx);
79
+ mkdirSync(shardDataDir, { recursive: true });
80
+ this.shards.set(shardId, router);
81
+ console.log(`[sh3] ${shardId} — static server routes mounted at /api/${shardId}/`);
82
+ }
83
+ /** Remove a shard's routes. */
84
+ unmount(shardId) {
85
+ const removed = this.shards.delete(shardId);
86
+ if (removed) {
87
+ console.log(`[sh3] ${shardId} — server routes unmounted`);
88
+ }
89
+ return removed;
90
+ }
91
+ /**
92
+ * Hono handler for the wildcard route.
93
+ * Looks up the shard sub-app and delegates with path stripping.
94
+ */
95
+ handler() {
96
+ return async (c, next) => {
97
+ const shardId = c.req.param('shardId');
98
+ const shardApp = this.shards.get(shardId);
99
+ if (!shardApp) {
100
+ return next();
101
+ }
102
+ try {
103
+ // Build a new URL with the shard prefix stripped.
104
+ // Original: /api/<shardId>/items/5 → shard sees: /items/5
105
+ const url = new URL(c.req.url);
106
+ const prefix = `/api/${shardId}`;
107
+ url.pathname = url.pathname.slice(prefix.length) || '/';
108
+ const strippedRequest = new Request(url.toString(), {
109
+ method: c.req.method,
110
+ headers: c.req.raw.headers,
111
+ body: c.req.raw.body,
112
+ // @ts-expect-error duplex needed for streaming bodies
113
+ duplex: 'half',
114
+ });
115
+ // Forward session from upstream sessionAuth so shard middleware can see it
116
+ const env = { ...c.env, session: c.get('session') ?? null };
117
+ return await shardApp.fetch(strippedRequest, env);
118
+ }
119
+ catch (err) {
120
+ console.error(`[sh3] Shard "${shardId}" runtime error:`, err);
121
+ return c.json({ error: `Shard "${shardId}" encountered an error` }, 500);
122
+ }
123
+ };
124
+ }
125
+ /** List all routes across all mounted shards (for /api/routes). */
126
+ listRoutes() {
127
+ const routes = [];
128
+ const seen = new Set();
129
+ for (const [shardId, app] of this.shards) {
130
+ for (const r of app.routes) {
131
+ const path = `/api/${shardId}${r.path}`;
132
+ const key = `${r.method} ${path}`;
133
+ if (seen.has(key))
134
+ continue;
135
+ seen.add(key);
136
+ routes.push({ method: r.method, path });
137
+ }
138
+ }
139
+ return routes;
140
+ }
141
+ /** Check if a shard is currently mounted. */
142
+ has(shardId) {
143
+ return this.shards.has(shardId);
144
+ }
145
+ }
@@ -0,0 +1,11 @@
1
+ export declare class HistoryStore {
2
+ private readonly path;
3
+ private readonly max;
4
+ private buffer;
5
+ constructor(dataDir: string, userId: string, historyMaxLines: number);
6
+ /** Read the capped in-memory view — most recent `historyMaxLines` lines. */
7
+ read(): string[];
8
+ /** Append a new line to both the on-disk file and the in-memory buffer. */
9
+ append(line: string): void;
10
+ private loadBuffer;
11
+ }
@@ -0,0 +1,65 @@
1
+ /*
2
+ * Per-user append-only history store for the shell-shard server.
3
+ *
4
+ * Storage format: JSONL at <dataDir>/history-<userId>.jsonl.
5
+ * Each line: { "ts": <ms>, "line": "<command>" }.
6
+ *
7
+ * The store holds a capped in-memory view (most recent `historyMaxLines`)
8
+ * that is served to clients on attach. The on-disk file is not rotated
9
+ * in v1 — rotation is in the deferred list. If the file exceeds 2× the
10
+ * cap, a warning is logged on load but the store still functions.
11
+ *
12
+ * A corrupt tail line (partial write from a crash mid-append) is skipped
13
+ * on load with a warning.
14
+ */
15
+ import { existsSync, appendFileSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ export class HistoryStore {
18
+ path;
19
+ max;
20
+ buffer;
21
+ constructor(dataDir, userId, historyMaxLines) {
22
+ this.path = join(dataDir, `history-${userId}.jsonl`);
23
+ this.max = historyMaxLines;
24
+ this.buffer = this.loadBuffer();
25
+ }
26
+ /** Read the capped in-memory view — most recent `historyMaxLines` lines. */
27
+ read() {
28
+ return [...this.buffer];
29
+ }
30
+ /** Append a new line to both the on-disk file and the in-memory buffer. */
31
+ append(line) {
32
+ const record = { ts: Date.now(), line };
33
+ appendFileSync(this.path, JSON.stringify(record) + '\n', 'utf-8');
34
+ this.buffer.push(line);
35
+ if (this.buffer.length > this.max) {
36
+ this.buffer.splice(0, this.buffer.length - this.max);
37
+ }
38
+ }
39
+ loadBuffer() {
40
+ if (!existsSync(this.path))
41
+ return [];
42
+ const raw = readFileSync(this.path, 'utf-8');
43
+ const lines = raw.split('\n');
44
+ const parsed = [];
45
+ for (const rawLine of lines) {
46
+ if (!rawLine.trim())
47
+ continue;
48
+ try {
49
+ const record = JSON.parse(rawLine);
50
+ if (typeof record.line === 'string') {
51
+ parsed.push(record.line);
52
+ }
53
+ }
54
+ catch {
55
+ // Corrupt tail line — skip silently. Log at debug level only.
56
+ console.warn(`[shell-shard/history] skipped corrupt line in ${this.path}`);
57
+ }
58
+ }
59
+ if (parsed.length > this.max * 2) {
60
+ console.warn(`[shell-shard/history] ${this.path} has ${parsed.length} lines; ` +
61
+ `cap is ${this.max}. Rotation is deferred — see spec § Deferred.`);
62
+ }
63
+ return parsed.slice(-this.max);
64
+ }
65
+ }
@@ -0,0 +1,14 @@
1
+ import { Hono } from 'hono';
2
+ import type { Context } from 'hono';
3
+ import type { WsLike } from './session-manager.js';
4
+ export interface ShellServerContext {
5
+ shardId: string;
6
+ dataDir: string;
7
+ adminOnly: any;
8
+ wsRegister: (onConnect: (ws: WsLike, c: Context) => void) => any;
9
+ }
10
+ declare const _default: {
11
+ id: string;
12
+ routes(app: Hono, ctx: ShellServerContext): Promise<void>;
13
+ };
14
+ export default _default;