Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
lib: ["server", "react", "client", "express", "elysia"]
lib: ["server", "react", "client", "express", "elysia", "h3"]
steps:
- uses: actions/checkout@v4

Expand Down
3 changes: 2 additions & 1 deletion lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"packages/nitro",
"packages/elysia",
"packages/react",
"packages/server"
"packages/server",
"packages/h3"
],
"version": "5.3.2-alpha.0",
"npmClient": "yarn",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"test:server": "yarn workspace @niledatabase/server test",
"test:client": "yarn workspace @niledatabase/client test",
"test:express": "yarn workspace @niledatabase/express test",
"test:elysia": "yarn workspace @niledatabase/elysia test"
"test:elysia": "yarn workspace @niledatabase/elysia test",
"test:h3": "yarn workspace @niledatabase/h3 test"
},
"resolutions": {
"@types/mime": "3.0.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/elysia/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"test": "NODE_OPTIONS=--experimental-vm-modules jest --forceExit"
},
"peerDependencies": {
"@niledatabase/server": ">=5.2.0",
"@niledatabase/server": ">=5.3.0",
"elysia": ">=1.0.0"
},
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"access": "public"
},
"peerDependencies": {
"@niledatabase/server": ">=5.2.0",
"@niledatabase/server": ">=5.3.0",
"express": "^5.0.0 || ^4.0.0"
},
"devDependencies": {
Expand Down
41 changes: 41 additions & 0 deletions packages/h3/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# @niledatabase/h3

Nile integration for H3 applications.

## Usage

This extension provides a "zero-config" experience for H3. By passing your `App` instance to the extension, it automatically registers a global middleware that:

1. Wraps every request in a Nile context (via `AsyncLocalStorage`).
2. Injects the `nile` singleton into `event.context`.

### Setup

```typescript
import { createApp } from "h3";
import Nile from "@niledatabase/server";
import { h3 } from "@niledatabase/h3";

const app = createApp();

const nile = await Nile({
extensions: [h3(app)], // Pass the app instance here
});
```

### Accessing Nile in Handlers

You can access the `nile` instance directly from the event context without importing it.

```typescript
app.use("/me", eventHandler(async (event) => {
// Access nile from the event context
// It is fully typed and ready to use
const user = await event.context.nile.users.getSelf();
return user;
}));
```

### Context Propagation

Because the middleware wraps the request context, you can also use singleton exports (if you have them) or other functions that rely on `nile.getInstance()`, and they will correctly pick up the current tenant/user context automatically.
4 changes: 4 additions & 0 deletions packages/h3/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
48 changes: 48 additions & 0 deletions packages/h3/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@niledatabase/h3",
"version": "5.3.1",
"license": "MIT",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
},
"prettier": {
"printWidth": 80,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts",
"test": "jest"
},
"author": "jrea",
"repository": {
"type": "git",
"url": "https://github.com/niledatabase/nile-js.git",
"directory": "packages/h3"
},
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@niledatabase/server": ">=5.3.0",
"h3": "^1.10.0"
},
"devDependencies": {
"@niledatabase/server": "workspace:^",
"@types/jest": "^30.0.0",
"h3": "^1.10.0",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"tsup": "^8.5.0"
}
}
106 changes: 106 additions & 0 deletions packages/h3/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// @ts-nocheck
import { createApp, eventHandler, toNodeListener, H3Event } from 'h3';
import { createServer } from 'http';

// Set Env vars before importing Nile server
process.env.NILEDB_ID = 'db-mock-id';
process.env.NILEDB_USER = 'db-mock-user';
process.env.NILEDB_PASSWORD = 'db-mock-password';
process.env.NILEDB_NAME = 'db-mock-name';
process.env.NILEDB_API_URL = 'http://localhost:1234';

describe('h3 extension', () => {
beforeEach(() => {
jest.resetModules();
});

it('should inject nile into event context and propagate request context', async () => {
const { Nile } = await import('@niledatabase/server');
const { h3 } = await import('./index');
const app = createApp();
// Use the REAL Nile server (no mocks)
const nile = await Nile({
extensions: [h3(app)],
});

app.use(
'/test',
eventHandler(async (event: H3Event) => {
if (!event.context.nile) {
throw new Error('Nile not attached to event context');
}

expect(event.context.nile).toBe(nile);

const ctx = nile.getContext();

const tenantIdHeader = ctx.headers.get('x-tenant-id');

return {
nilePresent: !!event.context.nile,
tenantIdHeader,
};
})
);

const response = await makeRequest(app, {
headers: {
'X-Tenant-Id': 'tenant-abc',
},
});

expect(response.nilePresent).toBe(true);
expect(response.tenantIdHeader).toBe('tenant-abc');
});

it('should register nile routes', async () => {
const { Nile } = await import('@niledatabase/server');
const { h3 } = await import('./index');
const app = createApp();
const nile = await Nile({
extensions: [h3(app)],
});

const path = nile.paths?.get?.[0];

if (!path) {
return;
}

const response = await makeRequest(app, { path });
// Expect not 404 (failed before)
expect(response.statusCode).not.toBe(404);
});
});

function makeRequest(
app: any,
options: { headers?: Record<string, string>; path?: string } = {}
): Promise<any> {
return new Promise((resolve) => {
const server = createServer(toNodeListener(app));
const req = server.listen(0, () => {
const port = (req.address() as any).port;
import('http').then((http) => {
const request = http.request(
`http://localhost:${port}${options.path || '/test'}`,
{
headers: options.headers,
},
(res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () =>
resolve({
...JSON.parse(data.startsWith('{') ? data : '{}'),
statusCode: res.statusCode,
})
);
server.close();
}
);
request.end();
});
});
});
}
142 changes: 142 additions & 0 deletions packages/h3/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
App,
createRouter,
eventHandler,
getHeaders,
getRequestURL,
readRawBody,
appendHeader,
H3Event,
} from 'h3';
import { Server, Extension } from '@niledatabase/server';

declare module 'h3' {
interface H3EventContext {
nile: Server;
}
}

export function cleaner(val: string) {
return val.replaceAll(/\{([^}]+)\}/g, ':$1');
}

const convertHeader = ([key, value]: [
string,
string | string[] | undefined
]) => [
key.toLowerCase(),
Array.isArray(value) ? value.join(', ') : String(value),
];

/**
* H3 extension for Nile.
* This ensures all handlers run within Nile's request context.
*/
export const h3 =
(app?: App): Extension =>
() => ({
id: 'h3',
onConfigure: (nile: Server) => {
if (app) {
// 1. Register global middleware to set context for every request
app.use(
eventHandler((event: H3Event) => {
// Attach nile to the event context
event.context.nile = nile;
// Use enterWith to transition the async context for the remainder of the request
nile.enterContext({
headers: new Headers(getHeaders(event) as HeadersInit),
});
})
);

// 2. Register Nile Routes
const router = createRouter();
const { paths: rawPaths } = nile;

const paths = {
GET: rawPaths.get.map(cleaner),
POST: rawPaths.post.map(cleaner),
PUT: rawPaths.put.map(cleaner),
DELETE: rawPaths.delete.map(cleaner),
};

const methods = ['GET', 'POST', 'PUT', 'DELETE'] as const;

methods.forEach((method) => {
paths[method].forEach((path) => {
/* eslint-disable no-console */
// console.log(`Registering route: [${method}] ${path}`);

const handler = eventHandler(async (event) => {
// console.log(`Matched route: [${method}] ${path}`);
// Adapt H3Event to Web Request (borrowed from Nitro extension logic)
const url = getRequestURL(event);
const reqHeaders = event.node.req.headers;
const headers: HeadersInit = reqHeaders
? Object.fromEntries(
Object.entries(reqHeaders).map(convertHeader)
)
: {};

const body = ['POST', 'PUT', 'PATCH'].includes(method)
? await readRawBody(event)
: null;

const requestInit: RequestInit = {
method,
headers,
body: body || null,
};

const request = new Request(url, requestInit);

// Call standard Nile handler
// Context is already set by global middleware above
const response = await nile.handlers[method](request as any);

if (response instanceof Response) {
response.headers.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') {
event.node.res.setHeader(key, value);
}
});

const setCookie = response.headers.getSetCookie?.();
if (setCookie && Array.isArray(setCookie)) {
setCookie.forEach((c) =>
appendHeader(event, 'Set-Cookie', c)
);
} else {
const cookie = response.headers.get('set-cookie');
if (cookie) {
appendHeader(event, 'Set-Cookie', cookie);
}
}

event.node.res.statusCode = response.status;
// Nile handlers return JSON or Text
const responseBody = await response
.json()
.catch(() => response.text());
return responseBody;
}
return response;
});

if (method === 'GET') {
router.get(path, handler);
} else if (method === 'POST') {
router.post(path, handler);
} else if (method === 'PUT') {
router.put(path, handler);
} else if (method === 'DELETE') {
router.delete(path, handler);
}
});
});

app.use(router.handler);
}
},
});
Loading