offsite 0.0.11 → 0.0.12

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.
Files changed (91) hide show
  1. package/README.md +15 -15
  2. package/apps/server/dist/main.mjs +141 -60
  3. package/apps/server/dist/main.mjs.map +1 -1
  4. package/apps/server/drizzle/20261017120143_device_tokens/migration.sql +15 -0
  5. package/apps/server/drizzle/20261017120143_device_tokens/snapshot.json +305 -0
  6. package/apps/web/dist/apple-touch-icon.png +0 -0
  7. package/apps/web/dist/assets/{angular-html-DTNmgvth.js → angular-html-DNGrmdhg.js} +1 -1
  8. package/apps/web/dist/assets/{angular-ts-DOOLa99A.js → angular-ts-D4hBqsOh.js} +1 -1
  9. package/apps/web/dist/assets/{apl-CSWmNqJI.js → apl-CYvdPOPE.js} +1 -1
  10. package/apps/web/dist/assets/{astro-CK7We5bo.js → astro-BqtVnRyx.js} +1 -1
  11. package/apps/web/dist/assets/{blade-B9Q94PMc.js → blade-BPJV2sdT.js} +1 -1
  12. package/apps/web/dist/assets/{c-D05zRIsd.js → c-CBQDE6Uf.js} +1 -1
  13. package/apps/web/dist/assets/{chapel-CJbjuVPR.js → chapel-BHOESU2p.js} +1 -1
  14. package/apps/web/dist/assets/{cobol-CE0VhU2R.js → cobol-CMpvzrel.js} +1 -1
  15. package/apps/web/dist/assets/{coffee-C0i-RL39.js → coffee-Bv27C06e.js} +1 -1
  16. package/apps/web/dist/assets/{cpp-CBJhfD_V.js → cpp-MTwxWDTP.js} +1 -1
  17. package/apps/web/dist/assets/{crystal-DRdgVTW1.js → crystal-CI5fSUCy.js} +1 -1
  18. package/apps/web/dist/assets/{css-DgMip4qW.js → css-BW8DHcmj.js} +1 -1
  19. package/apps/web/dist/assets/{edge-BCmeW_3H.js → edge-C2HOl06F.js} +1 -1
  20. package/apps/web/dist/assets/{elixir-BiRSxKnc.js → elixir-OV4G1_r7.js} +1 -1
  21. package/apps/web/dist/assets/{elm-zBiqCp9W.js → elm-BF0pK-yg.js} +1 -1
  22. package/apps/web/dist/assets/{erb-1QviMyT9.js → erb-DFQCtOmK.js} +1 -1
  23. package/apps/web/dist/assets/{git-rebase-TjFhp_Gn.js → git-rebase-C9nAAu5A.js} +1 -1
  24. package/apps/web/dist/assets/{glimmer-js-dThwC_hZ.js → glimmer-js-B9gnYdT1.js} +1 -1
  25. package/apps/web/dist/assets/{glimmer-ts-cCThNW17.js → glimmer-ts-BkG38Inm.js} +1 -1
  26. package/apps/web/dist/assets/{glsl-BxmyIQYG.js → glsl-wwo6dKBr.js} +1 -1
  27. package/apps/web/dist/assets/{graphql-DygyczJj.js → graphql-tQJRbYuy.js} +1 -1
  28. package/apps/web/dist/assets/{hack-Bl1_oIjY.js → hack-C9e9oyUG.js} +1 -1
  29. package/apps/web/dist/assets/{haml-CeOBocB0.js → haml-CTolMb7J.js} +1 -1
  30. package/apps/web/dist/assets/{handlebars-Bw1KMTCv.js → handlebars-CrJTM5vD.js} +1 -1
  31. package/apps/web/dist/assets/{html-BaNo1IFo.js → html-Be6zOrn3.js} +1 -1
  32. package/apps/web/dist/assets/{html-derivative-f4LhlsLO.js → html-derivative-10Bvy_Et.js} +1 -1
  33. package/apps/web/dist/assets/{http-Be2FOF-U.js → http-GZlGWwa-.js} +1 -1
  34. package/apps/web/dist/assets/{hurl-BrtPQQhS.js → hurl-DEYfhn1R.js} +1 -1
  35. package/apps/web/dist/assets/index-CiQ326rG.js +1203 -0
  36. package/apps/web/dist/assets/{java-Bum5bcia.js → java-BsFb26JS.js} +1 -1
  37. package/apps/web/dist/assets/{javascript-C5oYhZKk.js → javascript-DDLm5bzW.js} +1 -1
  38. package/apps/web/dist/assets/{jinja-CNXoe8pn.js → jinja-NBeNjUXf.js} +1 -1
  39. package/apps/web/dist/assets/{jison-g1hzxo4W.js → jison-DKz4RK3A.js} +1 -1
  40. package/apps/web/dist/assets/{json-CiAAuc5j.js → json-Bg4r8xCd.js} +1 -1
  41. package/apps/web/dist/assets/{jsx-Z0awY4ip.js → jsx-BMnPLKsa.js} +1 -1
  42. package/apps/web/dist/assets/{julia-DX3JjwcM.js → julia-Wll5K4al.js} +1 -1
  43. package/apps/web/dist/assets/{just-DS6eCX0g.js → just-BLn1iI69.js} +1 -1
  44. package/apps/web/dist/assets/{latex-CaBic_pJ.js → latex-D15HRF2E.js} +1 -1
  45. package/apps/web/dist/assets/{liquid-DMaOBf3v.js → liquid-COHkGI02.js} +1 -1
  46. package/apps/web/dist/assets/{lua-DeLz5pfA.js → lua-CkbAAxOH.js} +1 -1
  47. package/apps/web/dist/assets/{marko-DqlzpgnR.js → marko-Dmgo6IU5.js} +1 -1
  48. package/apps/web/dist/assets/{mdc-CRcSVpSn.js → mdc-D_OV_cCt.js} +1 -1
  49. package/apps/web/dist/assets/{nginx-BvszHPa-.js → nginx-ADn2lIMW.js} +1 -1
  50. package/apps/web/dist/assets/{nim-B19-Hebo.js → nim-C6zTq4FI.js} +1 -1
  51. package/apps/web/dist/assets/{org-Dx7yMTgG.js → org-D9XGvcFV.js} +1 -1
  52. package/apps/web/dist/assets/{perl-C-dISCJW.js → perl-B7-K-xLH.js} +1 -1
  53. package/apps/web/dist/assets/{php-BHBxrwsl.js → php-CO9ZzQRp.js} +1 -1
  54. package/apps/web/dist/assets/{pug-4a3x4vRs.js → pug-BaAyPwTO.js} +1 -1
  55. package/apps/web/dist/assets/{qml-CGOhzFqi.js → qml-Cp3pyCa-.js} +1 -1
  56. package/apps/web/dist/assets/{r-8jr0rtQL.js → r-CT05_vfW.js} +1 -1
  57. package/apps/web/dist/assets/{razor-BDrURPIA.js → razor-D5rHsTSp.js} +1 -1
  58. package/apps/web/dist/assets/{regexp-Cg-MfP18.js → regexp-D6kzd_X5.js} +1 -1
  59. package/apps/web/dist/assets/{rst-0OrP55QZ.js → rst-AkFwrhnR.js} +1 -1
  60. package/apps/web/dist/assets/{ruby-dAApWLhj.js → ruby-LNJDVfMG.js} +1 -1
  61. package/apps/web/dist/assets/{sas-3_hKTjL9.js → sas-C8o3xi__.js} +1 -1
  62. package/apps/web/dist/assets/{scss-D6ollEQv.js → scss-Ch7Tact0.js} +1 -1
  63. package/apps/web/dist/assets/{shellscript-V3H5Rwnn.js → shellscript-CN-vf6fZ.js} +1 -1
  64. package/apps/web/dist/assets/{shellsession-TxydKEoU.js → shellsession-CxId-awN.js} +1 -1
  65. package/apps/web/dist/assets/{soy-t3rHc1tL.js → soy-PXtFJtGw.js} +1 -1
  66. package/apps/web/dist/assets/{sql-B-NkeM8R.js → sql-B7js2_Lu.js} +1 -1
  67. package/apps/web/dist/assets/{stata-BW6neeVL.js → stata-C0Zwsf36.js} +1 -1
  68. package/apps/web/dist/assets/{surrealql-CRBKdx6K.js → surrealql-DawzxLLe.js} +1 -1
  69. package/apps/web/dist/assets/{svelte-DTTmtyn-.js → svelte-CbCoXemF.js} +1 -1
  70. package/apps/web/dist/assets/{templ-CCBYQttY.js → templ-CQyn0W4C.js} +1 -1
  71. package/apps/web/dist/assets/{tex-D9XxqR5N.js → tex-FeZKQwyK.js} +1 -1
  72. package/apps/web/dist/assets/{ts-tags-CI4pWHnF.js → ts-tags-KsJvoinT.js} +1 -1
  73. package/apps/web/dist/assets/{tsx-DlfK3Vuf.js → tsx-Cw6pGPeZ.js} +1 -1
  74. package/apps/web/dist/assets/{twig-D-C2s1f2.js → twig-Cj9MLPms.js} +1 -1
  75. package/apps/web/dist/assets/{typescript-BnqYTwqM.js → typescript-DwZR66Da.js} +1 -1
  76. package/apps/web/dist/assets/{typst-DdTyavvE.js → typst--K-bHYFW.js} +1 -1
  77. package/apps/web/dist/assets/{vue-zbXTTw4n.js → vue-BrB0TrD_.js} +1 -1
  78. package/apps/web/dist/assets/{vue-html-CIXNcZez.js → vue-html-BgNSGBvl.js} +1 -1
  79. package/apps/web/dist/assets/{vue-vine-Czo_W6u3.js → vue-vine-C2qLo1pd.js} +1 -1
  80. package/apps/web/dist/assets/{xml-DWv3jkXo.js → xml-DzdlZG_g.js} +1 -1
  81. package/apps/web/dist/assets/{xsl-BiggF1J3.js → xsl-D3HxAJEY.js} +1 -1
  82. package/apps/web/dist/assets/{yaml-D9oC3m1d.js → yaml-SX7TQG-a.js} +1 -1
  83. package/apps/web/dist/icon-192.png +0 -0
  84. package/apps/web/dist/icon-512.png +0 -0
  85. package/apps/web/dist/icon-maskable-512.png +0 -0
  86. package/apps/web/dist/icon.svg +4 -2
  87. package/apps/web/dist/index.html +3 -3
  88. package/apps/web/dist/manifest.webmanifest +1 -0
  89. package/apps/web/dist/sw.js +1 -1
  90. package/package.json +1 -1
  91. package/apps/web/dist/assets/index-NWUHLKte.js +0 -1203
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # offsite
1
+ <img src="apps/web/public/icon-512.png" width="96" alt="OffSite logo">
2
2
 
3
- Claude Code in your browser. It uses the same sessions as the CLI, so you can start in one and resume in the other.
3
+ # OffSite
4
4
 
5
- ## Usage
5
+ Claude Code, Codex and OpenCode in your browser. It uses the same sessions as their CLIs, so you can start in one and resume in the other.
6
6
 
7
- Requires Node.js 24+ and [Claude Code](https://code.claude.com), installed and logged in.
7
+ ## Usage
8
8
 
9
9
  ```sh
10
10
  npx offsite
@@ -20,21 +20,21 @@ Then open the pairing link it prints. It pairs this browser with the server, so
20
20
  | `offsite pair` | Makes a pairing token for another browser and prints its link. `--name` labels it |
21
21
  | `offsite unpair` | Lists paired browsers so you can pick which to unpair |
22
22
 
23
- | Flag | Default | What it sets |
24
- | ------------ | ------------ | ---------------------------------------------------------------------------------------- |
25
- | `--host` | `127.0.0.1` | Address to listen on |
26
- | `--port` | `47841` | Port to listen on |
27
- | `--dir` | `~/.offsite` | Folder for settings and accounts |
28
- | `--no-auth` | auth on | Accept connections without the pairing token |
29
- | `--no-pair` | pair on | Don't create a pairing token or print pairing links on start |
30
- | `--fallback` | none | Address the pairing links add for when this one is unreachable, e.g. a Cloudflare tunnel |
23
+ | Flag | Default | What it sets |
24
+ | ----------- | ------------ | ------------------------------------------------------------ |
25
+ | `--host` | `127.0.0.1` | Address to listen on |
26
+ | `--port` | `47841` | Port to listen on |
27
+ | `--dir` | `~/.offsite` | Folder for settings and accounts |
28
+ | `--no-auth` | auth on | Accept connections without the pairing token |
29
+ | `--no-pair` | pair on | Don't create a pairing token or print pairing links on start |
30
+ | `--pwa` | off | Serve the web app as an installable app that works offline |
31
31
 
32
- Pairing tokens stay in `tokens.json` inside `--dir`. A running offsite disconnects an unpaired browser within seconds.
32
+ Pairing tokens stay in `tokens.json` inside `--dir`. A running OffSite disconnects an unpaired browser within seconds.
33
33
 
34
34
  > [!WARNING]
35
- > With `--no-auth`, anyone who can reach its port, including any website open in your browser, can run Claude Code and open a terminal on your machine.
35
+ > With `--no-auth`, anyone who can reach its port, including any website open in your browser, can run your coding agents and open a terminal on your machine.
36
36
 
37
- Don't use the same session in offsite and the CLI at the same time. Switching between them is fine.
37
+ Don't use the same session in OffSite and the CLI at the same time. Switching between them is fine.
38
38
 
39
39
  ## Separate instances
40
40
 
@@ -1,3 +1,4 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
1
2
  import { chmodSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
3
  import { createServer } from "node:http";
3
4
  import path from "node:path";
@@ -5,7 +6,6 @@ import { consola } from "consola";
5
6
  import { WebSocketServer } from "ws";
6
7
  import os from "node:os";
7
8
  import { deleteSession, forkSession, getSessionMessages, getSubagentMessages, listSessions, query, renameSession } from "@anthropic-ai/claude-agent-sdk";
8
- import { createHash, randomBytes, randomUUID } from "node:crypto";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import readline from "node:readline";
11
11
  import { z } from "zod";
@@ -13,7 +13,7 @@ import { Command } from "@commander-js/extra-typings";
13
13
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
14
14
  import { OpenCode } from "@opencode/client";
15
15
  import { ensure, headers } from "@opencode/client/service";
16
- import { and, eq, inArray, sql } from "drizzle-orm";
16
+ import { and, eq, gt, inArray, lte, sql } from "drizzle-orm";
17
17
  import { sumBy } from "es-toolkit";
18
18
  import { DatabaseSync } from "node:sqlite";
19
19
  import { drizzle } from "drizzle-orm/node-sqlite";
@@ -127,7 +127,7 @@ var AppServer = class {
127
127
  this.ready = this.call("initialize", {
128
128
  clientInfo: {
129
129
  name: "offsite",
130
- title: "Offsite",
130
+ title: "OffSite",
131
131
  version: "0.0.0"
132
132
  },
133
133
  capabilities: {
@@ -698,12 +698,12 @@ function record$1(value) {
698
698
  //#region src/args.ts
699
699
  let command = "web";
700
700
  let name = null;
701
- const program = new Command("offsite").description("Starts the server and the web app").option("--host <host>", "Address to listen on", "127.0.0.1").option("--port <port>", "Port to listen on", "47841").option("--dir <dir>", "Folder for settings and accounts", path.join(os.homedir(), ".offsite")).option("--no-auth", "Accept connections without the pairing token").option("--no-pair", "Don't create a pairing token or print pairing links").option("--fallback <url>", "Address to try when this one is unreachable, e.g. a Cloudflare tunnel");
701
+ const program = new Command("offsite").description("Starts the server and the web app").option("--host <host>", "Address to listen on", "127.0.0.1").option("--port <port>", "Port to listen on", "47841").option("--dir <dir>", "Folder for settings and accounts", path.join(os.homedir(), ".offsite")).option("--no-auth", "Accept connections without pairing").option("--no-pair", "Don't create a pairing code or print pairing links").option("--pwa", "Serve the web app as an installable app that works offline");
702
702
  program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
703
703
  program.command("serve").description("Starts only the server, without the web app").action(() => {
704
704
  command = "serve";
705
705
  });
706
- program.command("pair").description("Creates a pairing token for another browser and prints its links").option("--name <name>", "Name to recognize this browser by").action((options) => {
706
+ program.command("pair").description("Creates a pairing code for another browser and prints its link").option("--name <name>", "Name to recognize this browser by").action((options) => {
707
707
  command = "pair";
708
708
  name = options.name ?? null;
709
709
  });
@@ -2423,10 +2423,16 @@ const sessions$1 = snakeCase.table("sessions", {
2423
2423
  "minimized"
2424
2424
  ] }).notNull()
2425
2425
  });
2426
- const tokens = snakeCase.table("tokens", {
2426
+ const pairingCodes = snakeCase.table("pairing_codes", {
2427
+ hash: text().primaryKey(),
2428
+ name: text(),
2429
+ expiresAt: integer().notNull()
2430
+ });
2431
+ const devices = snakeCase.table("devices", {
2427
2432
  hash: text().primaryKey(),
2428
2433
  name: text(),
2429
2434
  createdAt: integer().notNull(),
2435
+ expiresAt: integer().notNull(),
2430
2436
  lastUsedAt: integer()
2431
2437
  });
2432
2438
  const pushSubscriptions = snakeCase.table("push_subscriptions", {
@@ -5821,38 +5827,58 @@ async function answer(client, text) {
5821
5827
  }
5822
5828
  }
5823
5829
  //#endregion
5824
- //#region src/storage/token.ts
5825
- function readTokens() {
5826
- return db.select().from(tokens).orderBy(tokens.createdAt).all();
5830
+ //#region src/storage/auth.ts
5831
+ const CODE_LIFETIME = 3e5;
5832
+ const DEVICE_LIFETIME = 2592e6;
5833
+ function addCode(name) {
5834
+ const code = randomBytes(16).toString("base64url");
5835
+ db.delete(pairingCodes).where(lte(pairingCodes.expiresAt, Date.now())).run();
5836
+ db.insert(pairingCodes).values({
5837
+ hash: hash(code),
5838
+ name,
5839
+ expiresAt: Date.now() + CODE_LIFETIME
5840
+ }).run();
5841
+ return code;
5827
5842
  }
5828
- function addToken(name) {
5843
+ function redeemCode(code) {
5844
+ const found = db.delete(pairingCodes).where(eq(pairingCodes.hash, hash(code))).returning().get();
5845
+ if (found === void 0 || found.expiresAt <= Date.now()) return null;
5829
5846
  const token = randomBytes(32).toString("base64url");
5830
- db.insert(tokens).values({
5831
- hash: hashToken(token),
5832
- name,
5833
- createdAt: Date.now()
5847
+ const now = Date.now();
5848
+ db.insert(devices).values({
5849
+ hash: hash(token),
5850
+ name: found.name,
5851
+ createdAt: now,
5852
+ expiresAt: now + DEVICE_LIFETIME
5834
5853
  }).run();
5835
5854
  return token;
5836
5855
  }
5837
- function removeTokens(hashes) {
5838
- db.delete(tokens).where(inArray(tokens.hash, hashes)).run();
5856
+ function findDevice(token) {
5857
+ const found = db.select().from(devices).where(and(eq(devices.hash, hash(token)), gt(devices.expiresAt, Date.now()))).get();
5858
+ return found === void 0 ? null : found.hash;
5859
+ }
5860
+ function isPaired(device) {
5861
+ return db.select().from(devices).where(and(eq(devices.hash, device), gt(devices.expiresAt, Date.now()))).get() !== void 0;
5839
5862
  }
5840
- function hasTokens() {
5841
- return db.select().from(tokens).limit(1).get() !== void 0;
5863
+ function hasDevices() {
5864
+ return db.select().from(devices).where(gt(devices.expiresAt, Date.now())).limit(1).get() !== void 0;
5842
5865
  }
5843
- function matchesToken(given) {
5844
- if (given === null) return false;
5845
- return db.select().from(tokens).where(eq(tokens.hash, hashToken(given))).get() !== void 0;
5866
+ function readDevices() {
5867
+ return db.select().from(devices).orderBy(devices.createdAt).all();
5846
5868
  }
5847
- function recordUse(token) {
5848
- db.update(tokens).set({ lastUsedAt: Date.now() }).where(eq(tokens.hash, hashToken(token))).run();
5869
+ function removeDevices(hashes) {
5870
+ db.delete(devices).where(inArray(devices.hash, hashes)).run();
5849
5871
  }
5850
- function hashToken(token) {
5851
- return createHash("sha256").update(token).digest("hex");
5872
+ function recordUse(device) {
5873
+ db.update(devices).set({ lastUsedAt: Date.now() }).where(eq(devices.hash, device)).run();
5874
+ }
5875
+ function hash(secret) {
5876
+ return createHash("sha256").update(secret).digest("hex");
5852
5877
  }
5853
5878
  //#endregion
5854
5879
  //#region src/main.ts
5855
5880
  const UNPAIR_CHECK = 5e3;
5881
+ const TICKET_LIFETIME = 6e4;
5856
5882
  const contentTypes = {
5857
5883
  ".html": "text/html; charset=utf-8",
5858
5884
  ".js": "text/javascript; charset=utf-8",
@@ -5870,12 +5896,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5870
5896
  process.exit(1);
5871
5897
  }
5872
5898
  const address = `http://${args.host}:${port}`;
5873
- const fallback = args.fallback === void 0 ? null : readFallback(args.fallback);
5874
5899
  if (args.command === "pair") {
5875
- const links = pairingLinks(addToken(args.name));
5876
- console.log("Open this link in the browser to pair:");
5900
+ const links = pairingLinks(addCode(args.name));
5901
+ console.log("Open this link in a browser within 5 minutes to pair it:");
5877
5902
  console.log(` ${links.local}`);
5878
- if (fallback !== null) console.log(`Fallback: ${fallback}`);
5879
5903
  process.exit(0);
5880
5904
  }
5881
5905
  if (args.command === "unpair") {
@@ -5891,9 +5915,14 @@ if (findClaude() === null) {
5891
5915
  process.exit(1);
5892
5916
  }
5893
5917
  const publicDir = path.join(import.meta.dirname, "..", "..", "web", "dist");
5894
- const token = args.auth && args.pair && !hasTokens() ? addToken(null) : null;
5918
+ const code = args.auth && args.pair && !hasDevices() ? addCode(null) : null;
5919
+ const tickets = /* @__PURE__ */ new Map();
5895
5920
  const server = createServer((request, response) => {
5896
5921
  const url = new URL(request.url ?? "/", "http://localhost");
5922
+ if (url.pathname.startsWith("/api/")) {
5923
+ answerApi(url.pathname, request, response);
5924
+ return;
5925
+ }
5897
5926
  if (args.command === "serve") {
5898
5927
  response.statusCode = 404;
5899
5928
  response.end("offsite serve has no web app");
@@ -5905,26 +5934,27 @@ const sockets = new WebSocketServer({ noServer: true });
5905
5934
  const paired = /* @__PURE__ */ new Map();
5906
5935
  server.on("upgrade", (request, socket, head) => {
5907
5936
  const url = new URL(request.url ?? "/", "http://localhost");
5908
- const given = url.searchParams.get("token");
5909
- if (url.pathname !== "/ws" || !authorized(url)) {
5937
+ const device = args.auth ? redeemTicket(url.searchParams.get("ticket")) : null;
5938
+ if (url.pathname !== "/ws" || args.auth && device === null) {
5910
5939
  socket.destroy();
5911
5940
  return;
5912
5941
  }
5913
5942
  sockets.handleUpgrade(request, socket, head, (client) => {
5914
5943
  connectClient(client);
5915
- if (!args.auth || given === null) return;
5916
- paired.set(client, given);
5944
+ if (device === null) return;
5945
+ paired.set(client, device);
5917
5946
  client.on("close", () => paired.delete(client));
5918
- recordUse(given);
5947
+ recordUse(device);
5919
5948
  });
5920
5949
  });
5921
5950
  if (args.auth) setInterval(() => {
5922
- for (const [client, given] of paired) if (!matchesToken(given)) client.close();
5951
+ for (const [client, device] of paired) if (!isPaired(device)) client.close();
5952
+ for (const [ticket, item] of tickets) if (item.expiresAt <= Date.now()) tickets.delete(ticket);
5923
5953
  }, UNPAIR_CHECK);
5924
5954
  server.listen(port, args.host, () => {
5925
5955
  startPush(`${args.host}:${port}`);
5926
- if (args.pair && (token !== null || !args.auth)) {
5927
- const links = pairingLinks(token);
5956
+ if (args.pair && (code !== null || !args.auth)) {
5957
+ const links = pairingLinks(code);
5928
5958
  if (args.command === "web") console.log(`offsite is running at ${links.local}`);
5929
5959
  } else {
5930
5960
  console.log(`offsite is running at ${address}`);
@@ -5938,12 +5968,67 @@ function stop() {
5938
5968
  stopAll();
5939
5969
  process.exit(0);
5940
5970
  }
5941
- function authorized(url) {
5942
- return !args.auth || matchesToken(url.searchParams.get("token"));
5971
+ function answerApi(pathname, request, response) {
5972
+ response.setHeader("access-control-allow-origin", "*");
5973
+ if (request.method === "OPTIONS") {
5974
+ response.setHeader("access-control-allow-methods", "POST");
5975
+ response.setHeader("access-control-allow-headers", "authorization");
5976
+ response.statusCode = 204;
5977
+ response.end();
5978
+ return;
5979
+ }
5980
+ if (request.method !== "POST") {
5981
+ response.statusCode = 405;
5982
+ response.end("use POST");
5983
+ return;
5984
+ }
5985
+ const given = request.headers.authorization?.match(/^Bearer (.+)$/)?.[1] ?? null;
5986
+ if (pathname === "/api/pair") {
5987
+ const token = given === null ? null : redeemCode(given);
5988
+ if (token === null) {
5989
+ response.statusCode = 401;
5990
+ response.end("That pairing code expired or was already used");
5991
+ return;
5992
+ }
5993
+ answerJson(response, { token });
5994
+ return;
5995
+ }
5996
+ if (pathname === "/api/ticket") {
5997
+ if (!args.auth) {
5998
+ answerJson(response, { ticket: null });
5999
+ return;
6000
+ }
6001
+ const device = given === null ? null : findDevice(given);
6002
+ if (device === null) {
6003
+ response.statusCode = 401;
6004
+ response.end("This browser needs pairing");
6005
+ return;
6006
+ }
6007
+ const ticket = randomBytes(32).toString("base64url");
6008
+ tickets.set(ticket, {
6009
+ device,
6010
+ expiresAt: Date.now() + TICKET_LIFETIME
6011
+ });
6012
+ answerJson(response, { ticket });
6013
+ return;
6014
+ }
6015
+ response.statusCode = 404;
6016
+ response.end("not found");
6017
+ }
6018
+ function answerJson(response, body) {
6019
+ response.setHeader("content-type", "application/json");
6020
+ response.end(JSON.stringify(body));
6021
+ }
6022
+ function redeemTicket(ticket) {
6023
+ if (ticket === null) return null;
6024
+ const found = tickets.get(ticket);
6025
+ tickets.delete(ticket);
6026
+ if (found === void 0 || found.expiresAt <= Date.now()) return null;
6027
+ return found.device;
5943
6028
  }
5944
6029
  async function unpair() {
5945
- const tokens = readTokens();
5946
- if (tokens.length === 0) {
6030
+ const devices = readDevices();
6031
+ if (devices.length === 0) {
5947
6032
  console.log("No browsers are paired");
5948
6033
  return;
5949
6034
  }
@@ -5955,11 +6040,12 @@ async function unpair() {
5955
6040
  type: "multiselect",
5956
6041
  required: false,
5957
6042
  cancel: "null",
5958
- options: tokens.map((item) => ({
6043
+ options: devices.map((item) => ({
5959
6044
  value: item.hash,
5960
6045
  label: [
5961
6046
  item.name ?? "Unnamed",
5962
6047
  `paired ${new Date(item.createdAt).toLocaleString()}`,
6048
+ `${item.expiresAt <= Date.now() ? "expired" : "expires"} ${new Date(item.expiresAt).toLocaleString()}`,
5963
6049
  item.lastUsedAt === null ? "never used" : `last used ${new Date(item.lastUsedAt).toLocaleString()}`
5964
6050
  ].join(" · ")
5965
6051
  }))
@@ -5968,30 +6054,21 @@ async function unpair() {
5968
6054
  console.log("Nothing unpaired");
5969
6055
  return;
5970
6056
  }
5971
- removeTokens(picked);
6057
+ removeDevices(picked);
5972
6058
  console.log(`Unpaired ${picked.length} ${picked.length === 1 ? "browser" : "browsers"}; a running offsite disconnects them within seconds`);
5973
6059
  }
5974
- function readFallback(link) {
5975
- let url;
5976
- try {
5977
- url = new URL(link);
5978
- } catch {
5979
- console.error(`invalid fallback URL ${link}`);
5980
- process.exit(1);
5981
- }
5982
- if (url.protocol !== "http:" && url.protocol !== "https:") {
5983
- console.error(`the fallback URL ${link} needs http:// or https://`);
5984
- process.exit(1);
5985
- }
5986
- return url.origin;
5987
- }
5988
- function pairingLinks(token) {
6060
+ function pairingLinks(code) {
5989
6061
  return { local: `${address}/pair#${new URLSearchParams({
5990
6062
  machine: address,
5991
- ...token !== null && { token }
6063
+ ...code !== null && { code }
5992
6064
  })}` };
5993
6065
  }
5994
6066
  function serveFile(pathname, response) {
6067
+ if (!args.pwa && pathname === "/manifest.webmanifest") {
6068
+ response.statusCode = 404;
6069
+ response.end("offsite runs without --pwa");
6070
+ return;
6071
+ }
5995
6072
  const requested = path.join(publicDir, path.normalize(pathname));
5996
6073
  const file = requested.startsWith(publicDir + path.sep) && statSync(requested, { throwIfNoEntry: false })?.isFile() ? requested : path.join(publicDir, "index.html");
5997
6074
  if (!existsSync(file)) {
@@ -6000,6 +6077,10 @@ function serveFile(pathname, response) {
6000
6077
  return;
6001
6078
  }
6002
6079
  response.setHeader("content-type", contentTypes[path.extname(file)] ?? "application/octet-stream");
6080
+ if (!args.pwa && path.basename(file) === "index.html") {
6081
+ response.end(readFileSync(file, "utf8").replace(/<link rel="manifest"[^>]*>/, ""));
6082
+ return;
6083
+ }
6003
6084
  createReadStream(file).pipe(response);
6004
6085
  }
6005
6086
  //#endregion