Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ on:
branches: [main]
pull_request:

# Every job here only reads the checked-out code and runs local commands —
# none pushes, comments, or otherwise needs a writable GITHUB_TOKEN.
permissions:
contents: read

jobs:
format:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -53,3 +58,15 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
permissions:
contents: write # push version bump + tag, create release
packages: write # push Docker image to ghcr.io
id-token: write # OIDC for npm Trusted Publishing

steps:
- uses: actions/checkout@v4
Expand All @@ -26,6 +27,9 @@ jobs:
node-version: 22
cache: pnpm

# npm's OIDC Trusted Publishing needs npm >= 11.5.1; setup-node ships older.
- run: npm install -g npm@latest

- run: pnpm install --frozen-lockfile

# Inspect commit messages since the last tag (or all commits if no tag
Expand Down Expand Up @@ -78,6 +82,16 @@ jobs:
ghcr.io/${{ github.repository }}:${{ steps.version.outputs.new_version }}
ghcr.io/${{ github.repository }}:latest

# Publishes the library surface (src/index.ts + src/testing.ts) so
# other packages (e.g. @haverstack/cli's server-path tests) can depend
# on it — the Docker image above is the deployable app; this is the
# importable half. prepublishOnly builds dist/ first. Auth is the
# repo's Trusted Publisher (OIDC) — no NPM_TOKEN.
- name: Publish to npm
run: npm publish --provenance
env:
NPM_CONFIG_PROVENANCE: 'true'

# Commit the package.json bump, tag it, and push. Requires that the
# github-actions bot is allowed to push to main (configure via branch
# protection → "Allow specific actors to bypass required pull requests").
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ ENV NODE_ENV=production
ENV DB_PATH=/app/data/stack.db
EXPOSE 3000
USER app
CMD ["node", "dist/index.js"]
CMD ["node", "dist/main.js"]
24 changes: 22 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,30 @@
"engines": {
"node": ">=22.9"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts"
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node --env-file-if-exists=.env dist/index.js",
"start": "node --env-file-if-exists=.env dist/main.js",
"prepublishOnly": "pnpm run build",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint src tests",
Expand Down
70 changes: 16 additions & 54 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,16 @@
import { serve } from '@hono/node-server';
import pino from 'pino';
import { loadConfig } from './config.js';
import { initStack, type StackContext } from './stack.js';
import { createApp } from './app.js';
import { createShutdownHandler } from './shutdown.js';

const logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
transport:
process.env['NODE_ENV'] !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});

// Set once initStack() resolves, so the fatal-error handler below can flush
// a crash that happens after startup. Undefined before then: nothing to
// flush yet.
let ctx: StackContext | undefined;

async function main() {
const config = loadConfig();
ctx = await initStack(config, logger);
const app = createApp(ctx, config, logger);

logger.info({ dbPath: config.dbPath }, 'Stack initialized');

const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
logger.info({ port: info.port }, 'Server listening');
});

const shutdown = createShutdownHandler(server, ctx, logger, config.shutdownTimeoutMs);
const onSignal = (signal: string) => {
shutdown(signal)
.then(() => process.exit(0))
.catch((err) => {
logger.error({ err }, 'Error during shutdown');
process.exit(1);
});
};

process.on('SIGTERM', () => onSignal('SIGTERM'));
process.on('SIGINT', () => onSignal('SIGINT'));
}

main().catch(async (err) => {
logger.error({ err }, 'Fatal startup error');
if (ctx) {
await ctx.stack.flush().catch((flushErr) => {
logger.error({ err: flushErr }, 'Failed to flush during fatal-error shutdown');
});
}
process.exit(1);
});
/**
* @haverstack/server's library surface. The package is primarily an app
* (`dist/main.js`, run via `pnpm start` / the Docker image — see
* src/main.ts), but these exports let a consumer build and run the same
* Hono app in-process: embedding it behind their own listener, or driving a
* real instance in tests. See `@haverstack/server/testing` for the latter.
*/

export { createApp } from './app.js';
export type { AppEnv } from './types.js';
export { initStack } from './stack.js';
export type { StackContext } from './stack.js';
export { loadConfig } from './config.js';
export type { Config } from './config.js';
export { createShutdownHandler } from './shutdown.js';
export type { ShutdownServer } from './shutdown.js';
54 changes: 54 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { serve } from '@hono/node-server';
import pino from 'pino';
import { loadConfig } from './config.js';
import { initStack, type StackContext } from './stack.js';
import { createApp } from './app.js';
import { createShutdownHandler } from './shutdown.js';

const logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
transport:
process.env['NODE_ENV'] !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});

// Set once initStack() resolves, so the fatal-error handler below can flush
// a crash that happens after startup. Undefined before then: nothing to
// flush yet.
let ctx: StackContext | undefined;

async function main() {
const config = loadConfig();
ctx = await initStack(config, logger);
const app = createApp(ctx, config, logger);

logger.info({ dbPath: config.dbPath }, 'Stack initialized');

const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
logger.info({ port: info.port }, 'Server listening');
});

const shutdown = createShutdownHandler(server, ctx, logger, config.shutdownTimeoutMs);
const onSignal = (signal: string) => {
shutdown(signal)
.then(() => process.exit(0))
.catch((err) => {
logger.error({ err }, 'Error during shutdown');
process.exit(1);
});
};

process.on('SIGTERM', () => onSignal('SIGTERM'));
process.on('SIGINT', () => onSignal('SIGINT'));
}

main().catch(async (err) => {
logger.error({ err }, 'Fatal startup error');
if (ctx) {
await ctx.stack.flush().catch((flushErr) => {
logger.error({ err: flushErr }, 'Failed to flush during fatal-error shutdown');
});
}
process.exit(1);
});
165 changes: 165 additions & 0 deletions src/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Test-only building blocks for driving a real @haverstack/server. This
* package's own route tests use `createTestContext`/`testConfig` with
* `createApp` + Hono's in-process `app.request()` (no socket — see
* tests/setup.ts's `buildTestApp`/`req`); `startTestServer` goes one step
* further and actually listens, for a consumer whose client (APIAdapter or
* otherwise) needs a real URL to fetch against rather than a mocked one.
* Not for production use: fixed entity/token values, single-worker pool.
*/

import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { rm } from 'node:fs/promises';
import { mkdirSync } from 'node:fs';
import { createServer } from 'node:net';
import { serve } from '@hono/node-server';
import { LocalAdapter, NativeTokenStore, defaultTokenStorePath } from '@haverstack/adapter-local';
import { Stack } from '@haverstack/core';
import { authOriginFromUrl } from '@haverstack/core/wire';
import pino from 'pino';
import type { Logger } from 'pino';
import { createApp } from './app.js';
import { createShutdownHandler } from './shutdown.js';
import { AuthNonceStore, defaultNonceStorePath } from './lib/nonceStore.js';
import { QueryWorkerPool } from './lib/queryWorker/pool.js';
import { ChangeStreamRegistry } from './lib/changeStreams.js';
import type { Config } from './config.js';
import type { StackContext } from './stack.js';

export const TEST_ENTITY_ID = 'did:key:test-entity-id-00000001';
export const TEST_TOKEN = 'test-bearer-token';
export const OTHER_ENTITY_ID = 'did:key:other-entity-id-00000002';
// Matches @haverstack/conformance-fixtures' AUTH_FIXTURE_ORIGIN — the auth
// handshake fixtures carry real signatures over this exact origin, so
// anything replaying them (createTestContext/testConfig, in-process
// app.request()) must present itself as it. startTestServer, which actually
// listens, uses its own real origin instead — see below.
export const TEST_BASE_URL = 'https://stack.example.com';

export const logger: Logger = pino({ level: 'silent' });

/**
* Each caller gets its own isolated temp directory so the SQLiteAdapter's
* sibling `attachments/` folder never collides between parallel test runs.
*/
export function tempDbPath(): string {
const dir = join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}`);
mkdirSync(dir, { recursive: true });
return join(dir, 'stack.db');
}

export type TestContextOpts = {
/**
* IANA timezone string, or `undefined` to opt out of one. Note: a plain
* default parameter can't tell "omitted" from "explicitly undefined"
* (both trigger the default), so this must be an options bag — pass
* `{ timezone: undefined }` deliberately, not the bare value.
*/
timezone?: string;
};

export async function createTestContext(
dbPath: string,
opts: TestContextOpts = { timezone: 'UTC' },
): Promise<StackContext> {
const adapter = await LocalAdapter.initialize({
path: dbPath,
entityId: TEST_ENTITY_ID,
...(opts.timezone !== undefined && { timezone: opts.timezone }),
});
const stack = await Stack.create(adapter);
const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(dbPath) });
const nonces = AuthNonceStore.open(defaultNonceStorePath(dbPath));
const queryWorker = new QueryWorkerPool({
init: { dbPath },
poolSize: 1,
queueLimit: 64,
logger,
});
return { adapter, stack, tokens, nonces, queryWorker, changeStreams: new ChangeStreamRegistry() };
}

export function testConfig(dbPath: string, opts: TestContextOpts = { timezone: 'UTC' }): Config {
return {
port: 3000,
dbPath,
entityId: TEST_ENTITY_ID,
ownerName: null,
ownerHandle: null,
timezone: opts.timezone,
ownerToken: TEST_TOKEN,
corsOrigins: '*',
baseUrl: TEST_BASE_URL,
authOrigin: authOriginFromUrl(TEST_BASE_URL),
maxAttachmentBytes: 50 * 1024 * 1024,
maxContentBytes: 1 * 1024 * 1024,
queryTimeoutMs: 10_000,
queryWorkerPoolSize: 1,
queryQueueLimit: 64,
seedCommonsTypes: false,
shutdownTimeoutMs: 10_000,
};
}

/** Asks the OS for a free localhost port, then immediately releases it. */
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address ? address.port : undefined;
probe.close(() => {
if (port) resolve(port);
else reject(new Error('could not allocate a free port'));
});
});
});
}

export type TestServer = {
/** The real, reachable base URL — pass straight to APIAdapter.open({ url }). */
url: string;
ctx: StackContext;
config: Config;
/** Stops accepting connections and releases every resource, including the temp db. */
close(): Promise<void>;
};

/**
* Spins up a real, listening @haverstack/server on an ephemeral localhost
* port, backed by a throwaway LocalAdapter file — for exercising an HTTP
* client against the actual wire protocol rather than a mocked fetch.
* Owner auth: `Authorization: Bearer ${TEST_TOKEN}`.
*/
export async function startTestServer(
opts: TestContextOpts = { timezone: 'UTC' },
): Promise<TestServer> {
const dbPath = tempDbPath();
const ctx = await createTestContext(dbPath, opts);
const port = await getFreePort();
const url = `http://127.0.0.1:${port}`;
const config: Config = {
...testConfig(dbPath, opts),
port,
baseUrl: url,
authOrigin: authOriginFromUrl(url),
};
const app = createApp(ctx, config, logger);

const server = await new Promise<ReturnType<typeof serve>>((resolve) => {
const s = serve({ fetch: app.fetch, port }, () => resolve(s));
});

// Reuses the exact production teardown sequence (see src/shutdown.ts) —
// a short grace period since a stuck test should fail fast, not hang.
const shutdown = createShutdownHandler(server, ctx, logger, 2_000);
const close = async () => {
await shutdown('test-teardown');
await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
};

return { url, ctx, config, close };
}
Loading
Loading