overlay-factory-worker 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlay-factory-worker",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The Goose Tools Overlay Factory worker — your computer renders reel overlays for goosetools.com with Remotion and ffmpeg.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,6 @@
12
12
  * where that worker's installer already saved it. Set OVERLAY_WORKER_TOKEN to
13
13
  * override, or get a fresh token from /dashboard/overlay.
14
14
  */
15
- import { upload } from "@vercel/blob/client";
16
15
  import { execFileSync } from "node:child_process";
17
16
  import {
18
17
  existsSync,
@@ -54,6 +53,30 @@ import { resolveCustomFonts, type ResolvedFont } from "./custom-fonts";
54
53
  import { resolveFieldImages } from "./field-images";
55
54
  import { autoRestock, whatPrintsRestock } from "./wp-restock";
56
55
  import { ensureStateLinks } from "./state-dir";
56
+ import { acquireWorkerLock } from "./worker-lock";
57
+
58
+ // Worker-side upload, same signature as the old `upload` from
59
+ // "@vercel/blob/client": the upload route (worker token in clientPayload)
60
+ // hands back a signed PUT URL for Goose Tools' R2 bucket; the bytes go
61
+ // straight there.
62
+ async function upload(
63
+ pathname: string,
64
+ bytes: Buffer,
65
+ opts: { handleUploadUrl: string; clientPayload?: string; contentType?: string; access?: string; multipart?: boolean },
66
+ ): Promise<{ url: string; pathname: string }> {
67
+ const contentType = opts.contentType ?? "application/octet-stream";
68
+ const res = await fetch(opts.handleUploadUrl, {
69
+ method: "POST",
70
+ headers: { "content-type": "application/json" },
71
+ body: JSON.stringify({ pathname, contentType, size: bytes.length, clientPayload: opts.clientPayload ?? null }),
72
+ });
73
+ const grant = (await res.json().catch(() => ({}))) as { uploadUrl?: string; url?: string; pathname?: string; error?: string };
74
+ if (!res.ok || !grant.uploadUrl || !grant.url) throw new Error(grant.error ?? `upload not allowed (${res.status})`);
75
+ const put = await fetch(grant.uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: new Uint8Array(bytes) });
76
+ if (!put.ok) throw new Error(`upload PUT failed (${put.status})`);
77
+ return { url: grant.url, pathname: grant.pathname ?? pathname };
78
+ }
79
+
57
80
 
58
81
  // Installed from npm, the writable directories under ROOT are symlinks into
59
82
  // ~/.goosetools/overlay. Re-made on every start: `update` reinstalls the
@@ -62,6 +85,15 @@ import { ensureStateLinks } from "./state-dir";
62
85
  // anything reads or writes through one of those paths.
63
86
  ensureStateLinks();
64
87
 
88
+ // One worker of each kind per machine. This one collides the most easily: the
89
+ // daemon runs the installed package while a checkout is the natural place to
90
+ // try a change, and the two have different state directories behind them.
91
+ acquireWorkerLock("overlay", {
92
+ label: "The Overlay Factory worker",
93
+ stopHint:
94
+ "launchctl bootout gui/$(id -u)/com.goosetools.overlay (or close its terminal)",
95
+ });
96
+
65
97
  /**
66
98
  * Where the Goose Tools installer keeps this machine's worker credentials.
67
99
  * Reading it means the usual case needs no environment variables at all.
@@ -0,0 +1,106 @@
1
+ // One worker of each kind per machine.
2
+ //
3
+ // Nothing stops you starting a second worker: the daemon runs in the
4
+ // background, and `run` in a terminal is the normal way to watch one work or
5
+ // to try a change from a checkout. Both then poll the same queue with the same
6
+ // token.
7
+ //
8
+ // The server is safe — claiming a job is a single compare-and-set, so two
9
+ // workers never get the same one. The damage is quieter than that. They SPLIT
10
+ // the queue, so jobs land on whichever copy happened to pick them up: half
11
+ // from the code you're editing, half from the installed release, with a
12
+ // different state directory behind each. And the polling doubles, which is
13
+ // what the idle interval exists to keep down in the first place.
14
+ //
15
+ // So: whoever gets here first holds the lock, and the second one stands down
16
+ // with an explanation instead of quietly competing.
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const LOCK_DIR = join(homedir(), ".goosetools", "locks");
24
+
25
+ /**
26
+ * Take the lock for `name` ("worker", "caption", "brand", "overlay"), or print
27
+ * who has it and exit. Returns nothing — it either succeeds or ends the
28
+ * process.
29
+ *
30
+ * `stopHint` is the command that stops the OTHER copy, and it's the whole
31
+ * point of the message: "already running" without it just moves the puzzle.
32
+ */
33
+ export function acquireWorkerLock(
34
+ name: string,
35
+ { label, stopHint }: { label: string; stopHint: string },
36
+ ): void {
37
+ mkdirSync(LOCK_DIR, { recursive: true });
38
+ const file = join(LOCK_DIR, `${name}.pid`);
39
+
40
+ const holder = readHolder(file);
41
+ if (holder && isAlive(holder.pid)) {
42
+ console.log(
43
+ `\n${label} is already running on this computer (pid ${holder.pid}${
44
+ holder.since ? `, since ${holder.since}` : ""
45
+ }).\n\n` +
46
+ "Two of them would split the queue between them — some jobs done by\n" +
47
+ "one copy, some by the other. Stopping here instead.\n\n" +
48
+ ` Stop the other one: ${stopHint}\n`,
49
+ );
50
+ process.exit(0);
51
+ }
52
+
53
+ // Either no lock, or one left behind by a worker that was killed. Both are
54
+ // ours to take: an O_EXCL create loses to a worker that beat us here by
55
+ // milliseconds, which is the one race worth caring about.
56
+ if (holder) rmSync(file, { force: true });
57
+ let fd: number;
58
+ try {
59
+ fd = openSync(file, "wx");
60
+ } catch {
61
+ console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
62
+ process.exit(0);
63
+ }
64
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
65
+ closeSync(fd);
66
+
67
+ const release = () => rmSync(file, { force: true });
68
+ process.on("exit", release);
69
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
70
+ process.on(sig, () => {
71
+ release();
72
+ process.exit(0);
73
+ });
74
+ }
75
+ }
76
+
77
+ function readHolder(file: string): { pid: number; since: string | null } | null {
78
+ if (!existsSync(file)) return null;
79
+ try {
80
+ const [pid, since] = readFileSync(file, "utf8").split("\n");
81
+ const n = Number.parseInt(pid, 10);
82
+ return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ // A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
89
+ // pid alive" isn't enough on its own. Checking that it's a node process is
90
+ // cheap and rules out the reuse case that would otherwise lock out the daemon
91
+ // until someone deleted the file by hand.
92
+ function isAlive(pid: number): boolean {
93
+ try {
94
+ process.kill(pid, 0);
95
+ } catch {
96
+ return false;
97
+ }
98
+ try {
99
+ return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
100
+ encoding: "utf8",
101
+ stdio: ["ignore", "pipe", "ignore"],
102
+ }));
103
+ } catch {
104
+ return false;
105
+ }
106
+ }