@bloklabs/server 1.11.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.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @bloklabs/server
2
+
3
+ A small Go sidecar for [Blok](https://blokeditor.com). It answers the two requests a rich-text editor cannot make from the browser — storing an uploaded file, and reading the title/description/image of a pasted link — so you do not write a backend for either.
4
+
5
+ It stores no documents. There is no database, and nothing here is a hosted service you depend on: you run it.
6
+
7
+ ## Run it
8
+
9
+ ```bash
10
+ npx @bloklabs/server --listen 127.0.0.1:4000
11
+ ```
12
+
13
+ The npm package is a wrapper. On first run it downloads the binary for your platform from the matching GitHub release, checks it against the release's `checksums.txt`, and caches it. If your network blocks that download, the container image is the same build:
14
+
15
+ ```bash
16
+ docker run -p 4000:4000 ghcr.io/jackuait/blok-server --listen 0.0.0.0:4000
17
+ ```
18
+
19
+ ## Point the editor at it
20
+
21
+ ```ts
22
+ import { Blok } from '@bloklabs/core';
23
+
24
+ new Blok({
25
+ holder: 'editor',
26
+ uploader: {
27
+ async uploadByFile(file) {
28
+ const body = new FormData();
29
+
30
+ body.append('file', file);
31
+
32
+ const res = await fetch('http://127.0.0.1:4000/upload', { method: 'POST', body });
33
+
34
+ return res.json();
35
+ },
36
+ },
37
+ });
38
+ ```
39
+
40
+ The response shapes match what Blok's own parsers already expect, so nothing on the editor side needs configuring beyond the URL.
41
+
42
+ ## Routes
43
+
44
+ | Route | What it does |
45
+ | --- | --- |
46
+ | `GET /health` | Liveness plus the running version |
47
+ | `GET /unfurl?url=…` | Title, description and image for a pasted link |
48
+ | `POST /upload` | Stores an uploaded file, returns where it landed |
49
+ | `POST /upload-by-url` | Fetches a remote URL server-side and re-hosts it |
50
+
51
+ Both routes that fetch a URL the browser supplied go through one SSRF-guarded HTTP client, so a pasted `http://169.254.169.254/…` cannot reach your cloud metadata endpoint.
52
+
53
+ ## Access modes
54
+
55
+ `--auth` picks who may call the service, and the process refuses to start on a combination that would leave it open:
56
+
57
+ - `none` — no check, and therefore only allowed to bind loopback.
58
+ - `proxy` — trusts every caller, for when something in front of it already authenticated them; loopback only for the same reason.
59
+ - `ticket` — each request carries a short-lived pass your app signs with the shared secret. The only mode allowed to bind a public address.
60
+
61
+ Storage is a local directory by default (`--storage-dir`), or any S3-compatible bucket via `--s3-bucket` and friends. Credentials are read from `BLOK_S3_ACCESS_KEY` / `BLOK_S3_SECRET_KEY` — never from flags, which land in process listings.
62
+
63
+ ## Docs
64
+
65
+ Full flag reference and deployment notes: [https://blokeditor.com/docs](https://blokeditor.com/docs)
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npm wrapper for the blok-server Go binary.
4
+ *
5
+ * The package ships no binary. On first run it resolves the archive goreleaser
6
+ * published for this platform on the GitHub release matching this package's
7
+ * version, verifies it against that release's checksums.txt, unpacks it into a
8
+ * cache directory, and execs it. Later runs go straight to the cached binary.
9
+ *
10
+ * The version is the family version, not a server version: every release moves
11
+ * it whether or not the Go sources changed, which is why .github/workflows/
12
+ * release-server.yml publishes archives on EVERY tag and skips only the image.
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { createHash } from 'node:crypto';
16
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { homedir, tmpdir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ const REPO = 'https://github.com/JackUait/blok';
22
+ const IMAGE = 'ghcr.io/jackuait/blok-server';
23
+ const CHECKSUMS_FILE = 'checksums.txt';
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+
27
+ /** Mirrors the goos/goarch matrix in .goreleaser.yaml. */
28
+ const PLATFORMS = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
29
+ const ARCHITECTURES = { x64: 'amd64', arm64: 'arm64' };
30
+
31
+ /**
32
+ * @param {string} platform - process.platform
33
+ * @param {string} arch - process.arch
34
+ * @returns {{ os: string, arch: string, archive: string, binary: string } | null}
35
+ */
36
+ export function resolveTarget(platform, arch) {
37
+ const os = PLATFORMS[platform];
38
+ const goarch = ARCHITECTURES[arch];
39
+
40
+ if (os === undefined || goarch === undefined) {
41
+ return null;
42
+ }
43
+
44
+ // Must match archives.name_template + format_overrides in .goreleaser.yaml.
45
+ return {
46
+ os,
47
+ arch: goarch,
48
+ archive: `blok-server_${os}_${goarch}.${os === 'windows' ? 'zip' : 'tar.gz'}`,
49
+ binary: os === 'windows' ? 'blok-server.exe' : 'blok-server',
50
+ };
51
+ }
52
+
53
+ /** Every platform this wrapper can serve, for the error message. */
54
+ export function supportedTargets() {
55
+ return Object.keys(PLATFORMS)
56
+ .flatMap((platform) => Object.keys(ARCHITECTURES).map((arch) => `${platform}/${arch}`));
57
+ }
58
+
59
+ /**
60
+ * Read one asset's expected digest out of a goreleaser checksums file, whose
61
+ * lines are `<sha256><two spaces><filename>`.
62
+ *
63
+ * @param {string} contents - checksums.txt body
64
+ * @param {string} assetName - the archive filename to look up
65
+ * @returns {string | null} lowercase hex digest, or null when unlisted
66
+ */
67
+ export function checksumFor(contents, assetName) {
68
+ for (const line of contents.split('\n')) {
69
+ const [digest, name] = line.trim().split(/\s+/);
70
+
71
+ if (name === assetName && /^[0-9a-f]{64}$/i.test(digest ?? '')) {
72
+ return digest.toLowerCase();
73
+ }
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Where the binary is cached. The env override exists for locked-down machines
81
+ * where the home directory is read-only but some other path is not.
82
+ *
83
+ * @param {NodeJS.ProcessEnv} env
84
+ * @param {string} platform - process.platform
85
+ * @returns {string}
86
+ */
87
+ export function cacheRoot(env, platform) {
88
+ if (env.BLOK_SERVER_CACHE_DIR) {
89
+ return env.BLOK_SERVER_CACHE_DIR;
90
+ }
91
+
92
+ if (platform === 'win32') {
93
+ return join(env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'blok-server', 'Cache');
94
+ }
95
+
96
+ if (platform === 'darwin') {
97
+ return join(homedir(), 'Library', 'Caches', 'blok-server');
98
+ }
99
+
100
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'blok-server');
101
+ }
102
+
103
+ /**
104
+ * First of the candidate roots that actually accepts a write. A cache that
105
+ * cannot be written is not an error yet — the temp directory is a fine second
106
+ * home for a single run — so this only throws when nothing is writable.
107
+ *
108
+ * @param {string[]} candidates
109
+ * @returns {string}
110
+ */
111
+ function firstWritable(candidates) {
112
+ const failures = [];
113
+
114
+ for (const candidate of candidates) {
115
+ try {
116
+ mkdirSync(candidate, { recursive: true });
117
+
118
+ const probe = join(candidate, `.write-probe-${process.pid}`);
119
+
120
+ writeFileSync(probe, '');
121
+ rmSync(probe, { force: true });
122
+
123
+ return candidate;
124
+ } catch (error) {
125
+ failures.push(`${candidate} (${error.message})`);
126
+ }
127
+ }
128
+
129
+ throw new Error(`no writable cache directory: ${failures.join('; ')}`);
130
+ }
131
+
132
+ /**
133
+ * @param {string} url
134
+ * @returns {Promise<Buffer>}
135
+ */
136
+ async function download(url) {
137
+ const response = await fetch(url);
138
+
139
+ if (!response.ok) {
140
+ throw new Error(`GET ${url} → ${response.status} ${response.statusText}`);
141
+ }
142
+
143
+ return Buffer.from(await response.arrayBuffer());
144
+ }
145
+
146
+ /**
147
+ * Download, verify and unpack the archive, leaving the binary at `destination`.
148
+ *
149
+ * Everything happens inside a scratch directory next to the destination, and the
150
+ * finished binary is moved into place with a single rename: a run interrupted at
151
+ * any point leaves a scratch directory, never a truncated binary at the path the
152
+ * next run would execute.
153
+ *
154
+ * @param {{ version: string, target: ReturnType<typeof resolveTarget>, destination: string }} opts
155
+ */
156
+ async function fetchBinary({ version, target, destination }) {
157
+ const base = `${REPO}/releases/download/v${version}`;
158
+ // A 404 here means an unsigned download, so it aborts the run rather than
159
+ // falling through to "verification unavailable, running it anyway".
160
+ const checksums = (await download(`${base}/${CHECKSUMS_FILE}`)).toString('utf-8');
161
+ const expected = checksumFor(checksums, target.archive);
162
+
163
+ if (expected === null) {
164
+ throw new Error(`${CHECKSUMS_FILE} for v${version} does not list ${target.archive}`);
165
+ }
166
+
167
+ const scratch = mkdtempSync(join(dirname(destination), '.download-'));
168
+
169
+ try {
170
+ const archive = await download(`${base}/${target.archive}`);
171
+ const actual = createHash('sha256').update(archive).digest('hex');
172
+
173
+ if (actual !== expected) {
174
+ throw new Error(`checksum mismatch for ${target.archive}: expected ${expected}, got ${actual}`);
175
+ }
176
+
177
+ const archivePath = join(scratch, target.archive);
178
+
179
+ writeFileSync(archivePath, archive);
180
+
181
+ // -xf, never -xzf: bsdtar (macOS, and tar.exe on Windows) detects gzip and
182
+ // zip on its own, and -z would reject the Windows zip outright.
183
+ const extracted = spawnSync('tar', ['-xf', archivePath, '-C', scratch], { stdio: 'inherit' });
184
+
185
+ if (extracted.error) {
186
+ throw new Error(`could not run tar: ${extracted.error.message}`);
187
+ }
188
+
189
+ if (extracted.status !== 0) {
190
+ throw new Error(`tar exited with ${extracted.status} unpacking ${target.archive}`);
191
+ }
192
+
193
+ const unpacked = join(scratch, target.binary);
194
+
195
+ if (!existsSync(unpacked)) {
196
+ throw new Error(`${target.archive} did not contain ${target.binary}`);
197
+ }
198
+
199
+ chmodSync(unpacked, 0o755);
200
+
201
+ try {
202
+ renameSync(unpacked, destination);
203
+ } catch (error) {
204
+ // A concurrent run may have won the race; its binary passed the same
205
+ // checksum, so it is just as good. Windows rejects rename-over-existing.
206
+ if (!existsSync(destination)) {
207
+ throw error;
208
+ }
209
+ }
210
+ } finally {
211
+ rmSync(scratch, { recursive: true, force: true });
212
+ }
213
+ }
214
+
215
+ /**
216
+ * @param {string} version
217
+ * @param {string} reason
218
+ * @returns {string}
219
+ */
220
+ export function fallbackMessage(version, reason) {
221
+ return [
222
+ `blok-server: ${reason}`,
223
+ '',
224
+ 'The same build is published as a container image — run that instead:',
225
+ // The service defaults to loopback, which nothing outside the container can
226
+ // reach, so the published port needs an explicit bind address to match.
227
+ ` docker run -p 4000:4000 ${IMAGE}:${version} --listen 0.0.0.0:4000`,
228
+ '',
229
+ `Or download it by hand: ${REPO}/releases/tag/v${version}`,
230
+ ].join('\n');
231
+ }
232
+
233
+ async function main() {
234
+ const { version } = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf-8'));
235
+ const target = resolveTarget(process.platform, process.arch);
236
+
237
+ if (target === null) {
238
+ console.error(fallbackMessage(
239
+ version,
240
+ `no binary is published for ${process.platform}/${process.arch} (published: ${supportedTargets().join(', ')})`
241
+ ));
242
+ process.exit(1);
243
+ }
244
+
245
+ let binary;
246
+
247
+ try {
248
+ const root = firstWritable([
249
+ join(cacheRoot(process.env, process.platform), version),
250
+ join(tmpdir(), 'blok-server', version),
251
+ ]);
252
+
253
+ binary = join(root, target.binary);
254
+
255
+ if (!existsSync(binary)) {
256
+ await fetchBinary({ version, target, destination: binary });
257
+ }
258
+ } catch (error) {
259
+ console.error(fallbackMessage(version, error.message));
260
+ process.exit(1);
261
+ }
262
+
263
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
264
+
265
+ if (result.error) {
266
+ console.error(fallbackMessage(version, `could not start ${binary}: ${result.error.message}`));
267
+ process.exit(1);
268
+ }
269
+
270
+ process.exit(result.status ?? 1);
271
+ }
272
+
273
+ // Importing this file (the unit tests do) must not start a download.
274
+ //
275
+ // argv[1] MUST be realpath'd: npm installs a bin as node_modules/.bin/blok-server,
276
+ // a symlink to this file, and Node resolves import.meta.url through that symlink
277
+ // while leaving argv[1] as the link. Comparing them raw makes every `npx` run
278
+ // exit 0 having done nothing at all.
279
+ let isDirectRun = false;
280
+
281
+ if (process.argv[1] !== undefined) {
282
+ try {
283
+ isDirectRun = import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
284
+ } catch {
285
+ isDirectRun = false;
286
+ }
287
+ }
288
+
289
+ if (isDirectRun) {
290
+ await main();
291
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@bloklabs/server",
3
+ "version": "1.11.0",
4
+ "description": "Blok backend service — a small Go sidecar that handles file uploads and link previews, so you write no backend code for either.",
5
+ "license": "Apache-2.0",
6
+ "author": "JackUait",
7
+ "homepage": "https://blokeditor.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/JackUait/blok.git",
11
+ "directory": "packages/server"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/JackUait/blok/issues"
15
+ },
16
+ "keywords": [
17
+ "blok",
18
+ "block-editor",
19
+ "rich-text-editor",
20
+ "file-upload",
21
+ "uploads",
22
+ "link-preview",
23
+ "unfurl",
24
+ "open-graph",
25
+ "server",
26
+ "sidecar",
27
+ "ssrf"
28
+ ],
29
+ "bin": {
30
+ "blok-server": "bin/blok-server.mjs"
31
+ },
32
+ "files": [
33
+ "bin"
34
+ ],
35
+ "engines": {
36
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }