Skip to content

Commit

Permalink
Refactored service to bun
Browse files Browse the repository at this point in the history
Added structured logging
Added graceful shutdown
  • Loading branch information
luukvhoudt committed Mar 11, 2024
1 parent 03e17cb commit d038d9c
Show file tree
Hide file tree
Showing 18 changed files with 361 additions and 126 deletions.
16 changes: 10 additions & 6 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# do not copy/add files below to docker image on-build
.git
.gitignore
.dockerignore
Dockerfile
/examples
node_modules
examples
.git
.github
.gitignore
.dockerignore
.editorconfig
Dockerfile*
README.md
LICENSE
43 changes: 43 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# http://editorconfig.org

root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
tab_width = 4
trim_trailing_whitespace = true

# Overwrite specific file types
[*.{yml,yaml}]
indent_size = 2
indent_style = space

[*.php]
ij_php_spaces_around_pipe_in_union_type = true

[*.ts]
ij_typescript_chained_call_dot_on_new_line = true

# Overwrite for poeditor
[resources/language/*.json]
indent_style = space
indent_size = 4

# Overwrite specifics from vendors
[Component/**.php]
indent_size = 4
indent_style = tab

# Ignore vendor path
[vendor/**]
root = unset
charset = none
end_of_line = none
indent_style = none
insert_final_newline = none
tab_width = none
trim_trailing_whitespace = none
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ typings/
# Yarn Integrity file
.yarn-integrity

# IDE files
.idea

# dotenv environment variables file
.env

# generated files
*.pdf
43 changes: 39 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,41 @@
FROM surnet/alpine-node-wkhtmltopdf:8.11.3-0.12.5-full-font
FROM oven/bun:1-alpine as base
WORKDIR /usr/src/app

COPY index.js .
FROM base AS install
# Install all dependencies
RUN mkdir -p /temp/all
COPY package.json bun.lockb /temp/all/
RUN cd /temp/all && bun install --frozen-lockfile
# Install prod dependencies
RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production

EXPOSE 8000
CMD ["node", "index.js"]
FROM base AS prerelease
COPY --from=install /temp/all/node_modules node_modules
COPY . .

FROM surnet/alpine-wkhtmltopdf:3.19.0-0.12.6-small as wkhtmltopdf

FROM base AS release
RUN apk add --no-cache \
libstdc++ \
libx11 \
libxrender \
libxext \
libssl3 \
ca-certificates \
fontconfig \
freetype \
ttf-dejavu \
ttf-droid \
ttf-freefont \
ttf-liberation
COPY --from=wkhtmltopdf /bin /usr/local/bin
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/src .
COPY --from=prerelease /usr/src/app/package.json .

USER bun
EXPOSE 8000/tcp
CMD ["bun", "run", "index.ts"]
Binary file added bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
telemetry = false
19 changes: 0 additions & 19 deletions examples/html2pdf.php

This file was deleted.

8 changes: 0 additions & 8 deletions examples/html2pdf.py

This file was deleted.

2 changes: 1 addition & 1 deletion examples/html2pdf.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/usr/bin/env bash
curl http://localhost:8000 -d '<h1>Hello world from SHELL</h1>' > test.pdf
curl http://localhost:8000 -H 'Content-Type: text/html' -d '<html><body><h1>Hello world from SHELL</h1></body></html>' > "$(dirname $0)/test.pdf"
88 changes: 0 additions & 88 deletions index.js

This file was deleted.

15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "html-pdf-export",
"module": "src/index.ts",
"type": "module",
"dependencies": {
"nanoid": "^5.0.6",
"pino": "^8.19.0"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}
24 changes: 24 additions & 0 deletions src/html-to-pdf-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { spawn, which } from 'bun';

export type HtmlToPdfClient = (req: Request, outputPath: string) => Promise<void>;
export const htmlToPdfClient: HtmlToPdfClient = async (req, outputPath) => {
const bin = which('wkhtmltopdf');
if (bin === null) {
throw new Error('Missing HTML to PDF binary');
}
const proc = spawn(
['wkhtmltopdf', '--quiet', '--print-media-type', '--no-outline', '-', outputPath],
{stdin: req, stderr: 'pipe'},
);

const exitCode = await proc.exited;
const errors: string = await Bun.readableStreamToText(proc.stderr);
if (errors) {
throw new Error(errors);
}

// if no errors but unsuccessful exit code, throw a generic error
if (exitCode !== 0) {
throw new Error(`Failed to convert HTML to PDF, the process exited with code ${exitCode}`);
}
};
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createServer } from './server.ts';
import { trapShutdown } from './shutdown.ts';

const server = createServer();

trapShutdown(async () => server.stop());
7 changes: 7 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import pino, { type Logger as PinoLogger } from 'pino';

export const loggerUsingPino = () => pino({
name: 'html-pdf-export',
});

export type Logger = () => PinoLogger;
63 changes: 63 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { write } from 'bun';
import { afterEach, beforeEach, expect, mock, test } from 'bun:test';
import pino, { type Logger, type LoggerExtras } from 'pino';
import type { HtmlToPdfClient } from './html-to-pdf-client.ts';
import { createServer } from './server.ts';

mock.module('nanoid', () => ({nanoid: () => 'fake-random-id'}));

const port = 0; // 0 means give a random unassigned port
const host = 'http://localhost';
const method = 'POST';
const body = "<h1>Hello world</h1>";
const headers = {'content-type': 'text/html'};
const logger = {
info: mock() as pino.LogFn,
error: mock() as pino.LogFn,
child: mock() as LoggerExtras['child'],
} as Logger;
const htmlToPdfClient: HtmlToPdfClient = async (req, outputPath) => {
const html = await req.text();
await write(outputPath, html);
};

let server: ReturnType<typeof createServer>;
beforeEach(() => server = createServer({port, htmlToPdfClient, logger: () => logger}));
afterEach(() => server.stop());

test('logs request id', async () => {
await server.fetch(new Request(host));
expect(logger.child).toHaveBeenCalledWith({requestId: 'fake-random-id'});
});

const invalidRequestMethods = ['GET', 'HEAD', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'];
test.each(invalidRequestMethods)('cannot do %s requests', async method => {
const res = await server.fetch(new Request(host, {method}));
expect(res.status).toBe(405);
expect(logger.error).toHaveBeenCalledWith('Invalid request method');
});

test('requires a request body', async () => {
const res = await server.fetch(new Request(host, {method}));
expect(res.status).toBe(400);
expect(logger.error).toHaveBeenCalledWith('Missing request body');
});

test('requires a content-type request header', async () => {
const res = await server.fetch(new Request(host, {method, body}));
expect(res.status).toBe(400);
expect(logger.error).toHaveBeenCalledWith('Missing content-type request header');
});

test('requires a request with text/html content-type header', async () => {
const res = await server.fetch(new Request(host, {method, body, headers: {'content-type': ''}}));
expect(res.status).toBe(400);
expect(logger.error).toHaveBeenCalledWith('Invalid content-type request header');
});

test('success', async () => {
const res = await server.fetch(new Request(host, {method, body, headers}));
expect(res.status).toBe(200);
expect(await res.text()).toBe(body);
expect(res.headers.get('content-type')).toBe('application/pdf');
});
Loading

0 comments on commit d038d9c

Please sign in to comment.