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
131 changes: 85 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,54 @@
# Kaksik
Middleware library for creating applications for [Gemini](https://gemini.circumlunar.space) protocol
on top of [Deno](https://deno.land) runtime, written in TypeScript.
# ♊️ qgeminiserver

Heavily inspired by [oak](https://github.com/oakserver/oak) and [denoscuri](https://github.com/caranatar/denoscuri).
[![JSR](https://jsr.io/badges/@arma/qgeminiserver)](https://jsr.io/@arma/qgeminiserver)

## Feature roadmap
- [x] Serve gemtext (out of the box, see [Gemtext usage](#gemtext-usage))
- [x] Serve static files at configured URLs (via middleware, see [serveStatic](#servestatic))
- [x] Serve programmable resources at configured URLs (via middleware, see [handleRoutes](#handleroutes))
- [x] Serve redirect responses at configured URLs (via middleware, see [handleRedirects](#handleredirects))
- [x] Document `Gemtext` usage
- [ ] Serve gone responses at configured URLs (via middleware)
- [ ] Improve `Response` class
- [ ] -- 'Good enough' point --
- [ ] *Propose yours by [filing an issue](https://github.com/sergetymo/kaksik/issues/new)*
A Deno+TypeScript framework for building [Geminispace](https://geminiquickst.art/) servers. This is a [Kaksik](https://github.com/sergetymo/kaksik) fork that brings bug-fixes and keeps away bit-rot.

## Usage

### Prerequisites
1. [Install](https://deno.land/#installation) Deno executable
2. Obtain SSL certificates. You can generate self-signed ones using `openssl` command:

1. [Install Deno 2+](https://deno.com/)
1. Obtain SSL certificates. You can generate self-signed ones using `openssl` command:

```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
```

### Your first app

Create minimal application in `app.ts`:

```typescript
import { Application } from 'https://deno.land/x/kaksik/mod.ts'
import { Application } from 'jsr:@arma/qgeminiserver@2.0.3';

const app = new Application({
keyFile: '/path/to/key.pem',
certFile: '/path/to/cert.pem',
})
const
keyPath = Deno.env.get('KEY_PATH') || '../cert/key.pem',
certPath = Deno.env.get('CERT_PATH') || '../cert/cert.pem',
key = await Deno.readTextFile(keyPath),
cert = await Deno.readTextFile(certPath),
app = new Application({key, cert});

app.use(ctx => {
ctx.response.body = '# Hello World!'
})
});

await app.start()
await app.start();
```

Then run it:

```bash
deno run --allow-net --allow-read app.ts
```

### Gemtext usage

`Gemtext` class represents a `text/gemini` media type that is native to Gemini protocol
(see chapter 5 of [spec](https://gemini.circumlunar.space/docs/specification.html)).
It's a line-based text format, so essentially `Gemtext` is just an `Array<Line>` with helpers.
All six line types are implemented:

- [x] `LineText`
- [x] `LineLink`
- [x] `LinePreformattedToggle`
Expand All @@ -60,10 +59,14 @@ All six line types are implemented:
`Response.body` setter accepts Gemtext for convenience.

```typescript
const app = new Application({
keyFile: '/path/to/key.pem',
certFile: '/path/to/cert.pem',
})
import { Application } from 'jsr:@arma/qgeminiserver@2.0.3';

const
keyPath = Deno.env.get('KEY_PATH') || '../cert/key.pem',
certPath = Deno.env.get('CERT_PATH') || '../cert/cert.pem',
key = await Deno.readTextFile(keyPath),
cert = await Deno.readTextFile(certPath),
app = new Application({key, cert});

app.use(ctx => {
ctx.response.body = new Gemtext(
Expand All @@ -84,6 +87,7 @@ await app.start()
```

Appending new lines and other `Gemtext` instances:

```typescript
const content = new Gemtext(
new LineHeading('Second page', 1),
Expand Down Expand Up @@ -120,43 +124,51 @@ content.append(
```

### Other examples
See `examples` folder.

See `examples` folder.

## Available middleware

### serveStatic

Serves static files from a directory to specified URL

```typescript
import { Application, serveStatic } from 'https://deno.land/x/kaksik/mod.ts'
import { Application, serveStatic } from 'jsr:@arma/qgeminiserver@2.0.3';

const app = new Application({
keyFile: '/path/to/key.pem',
certFile: '/path/to/cert.pem',
})
const
keyPath = Deno.env.get('KEY_PATH') || '../cert/key.pem',
certPath = Deno.env.get('CERT_PATH') || '../cert/cert.pem',
key = await Deno.readTextFile(keyPath),
cert = await Deno.readTextFile(certPath),
app = new Application({key, cert});

app.use(serveStatic('./log/', '/gemlog/'))
app.use(serveStatic('./public/'))

await app.start()
```

Beware of ordering of `serveStatic` middleware usages: more generic URLs should occur
later that more specific, e.g., `/path/subpath/` must be before `/path/`.


### handleRoutes

Runs specified async function when request path matches configured route.

```typescript
import {
Application,
handleRoutes,
Route,
} from 'https://deno.land/x/kaksik/mod.ts'
} from 'jsr:@arma/qgeminiserver@2.0.3'

const app = new Application({
keyFile: '/path/to/key.pem',
certFile: '/path/to/cert.pem',
})
const
keyPath = Deno.env.get('KEY_PATH') || '../cert/key.pem',
certPath = Deno.env.get('CERT_PATH') || '../cert/cert.pem',
key = await Deno.readTextFile(keyPath),
cert = await Deno.readTextFile(certPath),
app = new Application({key, cert});

app.use(handleRoutes(
new Route('/test', async (ctx) => {
Expand All @@ -183,20 +195,24 @@ await app.start()
```

### handleRedirects

Sends either temporary or permanent redirect response when path matches configuration.

```typescript
import {
Application,
handleRedirects,
handleRoutes,
Redirect,
Route,
} from 'https://deno.land/x/kaksik/mod.ts'
} from 'jsr:@arma/qgeminiserver@2.0.3'

const app = new Application({
keyFile: '/path/to/key.pem',
certFile: '/path/to/cert.pem',
})
const
keyPath = Deno.env.get('KEY_PATH') || '../cert/key.pem',
certPath = Deno.env.get('CERT_PATH') || '../cert/cert.pem',
key = await Deno.readTextFile(keyPath),
cert = await Deno.readTextFile(certPath),
app = new Application({key, cert});

app.use(handleRedirects(
new Redirect('/short', '/long-very-long-url', true),
Expand All @@ -212,5 +228,28 @@ app.use(handleRoutes(
await app.start()
```

## Trivia
"Kaksik" means "twin" in Estonian.
## Feature roadmap

- [x] Serve gemtext (out of the box, see [Gemtext usage](#gemtext-usage))
- [x] Serve static files at configured URLs (via middleware, see [serveStatic](#servestatic))
- [x] Serve programmable resources at configured URLs (via middleware, see [handleRoutes](#handleroutes))
- [x] Serve redirect responses at configured URLs (via middleware, see [handleRedirects](#handleredirects))
- [x] Document `Gemtext` usage
- [ ] Serve gone responses at configured URLs (via middleware)
- [ ] Improve `Response` class
- [ ] -- 'Good enough' point --
- [ ] *Propose yours by [filing an issue](https://github.com/sergetymo/kaksik/issues/new)*

## Dependencies

[geminispace-jsdoc-server](https://github.com/doga/geminispace-jsdoc-server) depends on this TypeScript module.

```mermaid
---
title: Dependencies
---
flowchart LR
GeminispaceJSDocServer[geminispace-jsdoc-server] --imports--> QGeminiServer[qgeminiserver]
```

119 changes: 119 additions & 0 deletions classes/Application.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Middleware, compose } from './Middleware.mts';
import { Context } from './Context.mts';
import { ResponseFailure } from './ResponseFailure.mts';

export type Config = Deno.ListenTlsOptions & Deno.TlsCertifiedKeyPem;
export type ConfigDefaults = Required<Pick<Config, 'hostname' | 'port'>>;
export type ConfigArgument =
Partial<Pick<Config, 'hostname' | 'port'>>
& Required<Pick<Config, 'key' | 'cert'>>;

// deno-lint-ignore no-explicit-any
export type State = Record<string | number | symbol, any>

export class Application <S extends State> {
public state: S
public readonly config: Config

private server?: Deno.Listener
private decoder: TextDecoder
private isStarted = false
private middleware: Array<Middleware<S, Context<S>>> = []
private composed?: (context: Context<S>) => Promise<void>

constructor (config: ConfigArgument, initialState: S = {} as S) {
this.decoder = new TextDecoder()
this.state = initialState
this.config = Object.assign<ConfigDefaults, ConfigArgument>({
hostname: 'localhost',
port: 1965,
}, config)
}

public async start (): Promise<void> {
this.server = Deno.listenTls(this.config)
this.isStarted = true
console.log(`Listening on ${this.config.hostname}:${this.config.port}`)
while (this.isStarted) {
for await (const connection of this.server) {
try {
await this.handleConnection(connection)
} catch (_error) {
// console.log(error)
}
}
}
}

public use <AS extends State = S> (
...middleware: Array<Middleware<AS, Context<AS>>>
): Application<AS extends S ? AS : (AS & S)> {
this.middleware.push(...middleware)
this.composed = undefined
// deno-lint-ignore no-explicit-any
return this as Application<any>
}

private compose (): (ctx: Context<S>) => Promise<void> {
if (!this.composed) this.composed = compose(this.middleware)
return this.composed
}

private async handleConnection (connection: Deno.Conn): Promise<void> {
const
crlf = '\r\n', // ends a request line
inputReader = connection.readable.getReader(); // https://docs.deno.com/examples/streaming-files

let
request = '',
done = false;

do {
const result = await inputReader.read();
done = result.done;

if (result.value) {
request += this.decoder.decode(result.value);
if (request.indexOf(crlf) > 0) {
request = request.split(crlf)[0].trim();
break;
}
}
} while (!done);

const ctx = new Context(this, request);

try {
await this.compose()(ctx)
} catch (_error) {
ctx.response = new ResponseFailure()
}

const outputWriter = connection.writable.getWriter();
await outputWriter.ready;

await outputWriter.write(ctx.response.contents)
try {
await outputWriter.close();
} catch (_error) {
//
}
// connection.close();
}

// private async handleConnection (connection: Deno.Conn): Promise<void> {
// const buffer = new Uint8Array(1026)
// const length = await connection.read(buffer)
// if (!length) return void 0
// const requestString = this.decoder.decode(buffer.subarray(0, length))
// const ctx = new Context(this, requestString)
// try {
// await this.compose()(ctx)
// } catch (_error) {
// ctx.response = new ResponseFailure()
// }
// await connection.write(ctx.response.contents)
// connection.close();
// }

}
Loading