authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-10 00:12:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 00:12:52-07:00
logcf9f236c80aaa8ea4c3827d98362b752759d1ec6
tree98586ad047f67b20505022f9b4847cff545735ae
parente82b98738697e58644fef8d0836bf730c2b45613
signature Signed by PGP key B5690EEEBB952194

Add headless `t3 serve` pairing output (#1871)


20 files changed, 2355 insertions(+), 1173 deletions(-)

REMOTE.md+50-46
...@@ -1,68 +1,72 @@...@@ -1,68 +1,72 @@
1# Remote Access Setup1# Remote Access
22
3Use this when you want to open T3 Code from another device (phone, tablet, another laptop).3Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.
44
5## CLI ↔ Env option map5## Recommended Setup
66
7The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:7Use a trusted private network that meshes your devices together, such as a tailnet.
88
9| CLI flag | Env var | Notes |9That gives you:
10| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
18| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |
1910
20> TIP: Use the `--help` flag to see all available options and their descriptions.11- a stable address to connect to
12- transport security at the network layer
13- less exposure than opening the server to the public internet
2114
22## Security First15## Headless Server Flow
2316
24- Always set `--auth-token` before exposing the server outside localhost.17Run the server with `t3 serve`.
25 - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
26 With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments.
27- Treat the token like a password.
28- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
29
30## 1) Build + run server for remote access
31
32Remote access should use the built web app (not local Vite redirect mode).
3318
34```bash19```bash
35bun run build20npx t3 serve --host "$(tailscale ip -4)"
36TOKEN="$(openssl rand -hex 24)"
37bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
38```21```
3922
40Then open on your phone:23`t3 serve` starts the server without opening a browser and prints:
4124
42`http://<your-machine-ip>:3773`25- a connection string
26- a pairing token
27- a pairing URL
28- a QR code for the pairing URL
4329
44Example:30From there, connect from another device in either of these ways:
4531
46`http://192.168.1.42:3773`32- scan the QR code on your phone
33- in the desktop app, enter the full pairing URL
34- in the desktop app, enter the host and token separately
4735
48Notes:36Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument.
4937
50- `--host 0.0.0.0` listens on all IPv4 interfaces.38> Note
51- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions.39> The GUIs do not currently support adding projects on remote environments.
52- Ensure your OS firewall allows inbound TCP on the selected port.40> For now, use `t3 project ...` on the server machine instead.
41> Full GUI support for remote project management is coming soon.
5342
54## 2) Tailnet / Tailscale access43## How Pairing Works
5544
56If you use Tailscale, you can bind directly to your Tailnet address.45The remote device does not need a long-lived secret up front.
5746
58```bash47Instead:
59TAILNET_IP="$(tailscale ip -4)"48
60TOKEN="$(openssl rand -hex 24)"491. `t3 serve` issues a one-time owner pairing token.
61bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser502. The remote device exchanges that token with the server.
62```513. The server creates an authenticated session for that device.
52
53After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.
54
55## Managing Access Later
56
57Use `t3 auth` to manage access after the initial pairing flow.
58
59Typical uses:
60
61- issue additional pairing credentials
62- inspect active sessions
63- revoke old pairing links or sessions
6364
64Open from any device in your tailnet:65Use `t3 auth --help` and the nested subcommand help pages for the full reference.
6566
66`http://<tailnet-ip>:3773`67## Security Notes
6768
68You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.69- Treat pairing URLs and pairing tokens like passwords.
70- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
71- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.
72- Use `t3 auth` to revoke credentials or sessions you no longer trust.
apps/server/src/auth/Layers/ServerAuthPolicy.ts+1-17
...@@ -4,23 +4,7 @@ import { Effect, Layer } from "effect";...@@ -4,23 +4,7 @@ import { Effect, Layer } from "effect";
4import { ServerConfig } from "../../config.ts";4import { ServerConfig } from "../../config.ts";
5import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";5import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
6import { SESSION_COOKIE_NAME } from "../utils.ts";6import { SESSION_COOKIE_NAME } from "../utils.ts";
77import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
8const isWildcardHost = (host: string | undefined): boolean =>
9 host === "0.0.0.0" || host === "::" || host === "[::]";
10
11const isLoopbackHost = (host: string | undefined): boolean => {
12 if (!host || host.length === 0) {
13 return true;
14 }
15
16 return (
17 host === "localhost" ||
18 host === "127.0.0.1" ||
19 host === "::1" ||
20 host === "[::1]" ||
21 host.startsWith("127.")
22 );
23};
248
25export const makeServerAuthPolicy = Effect.gen(function* () {9export const makeServerAuthPolicy = Effect.gen(function* () {
26 const config = yield* ServerConfig;10 const config = yield* ServerConfig;
apps/server/src/auth/Layers/SessionCredentialService.ts+21-20
...@@ -46,6 +46,9 @@ const WebSocketClaims = Schema.Struct({...@@ -46,6 +46,9 @@ const WebSocketClaims = Schema.Struct({
46});46});
47type WebSocketClaims = typeof WebSocketClaims.Type;47type WebSocketClaims = typeof WebSocketClaims.Type;
4848
49const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
50const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
51
49function createDefaultClientMetadata(): AuthClientMetadata {52function createDefaultClientMetadata(): AuthClientMetadata {
50 return {53 return {
51 deviceType: "unknown",54 deviceType: "unknown",
...@@ -259,15 +262,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {...@@ -259,15 +262,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {
259 });262 });
260 }263 }
261264
262 const claims = yield* Effect.try({265 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
263 try: () =>266 Effect.mapError(
264 Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))),267 (cause) =>
265 catch: (cause) =>268 new SessionCredentialError({
266 new SessionCredentialError({269 message: "Invalid session token payload.",
267 message: "Invalid session token payload.",270 cause,
268 cause,271 }),
269 }),272 ),
270 });273 );
271274
272 const now = yield* Clock.currentTimeMillis;275 const now = yield* Clock.currentTimeMillis;
273 if (claims.exp <= now) {276 if (claims.exp <= now) {
...@@ -348,17 +351,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {...@@ -348,17 +351,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {
348 });351 });
349 }352 }
350353
351 const claims = yield* Effect.try({354 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
352 try: () =>355 Effect.mapError(
353 Schema.decodeUnknownSync(WebSocketClaims)(356 (cause) =>
354 JSON.parse(base64UrlDecodeUtf8(encodedPayload)),357 new SessionCredentialError({
355 ),358 message: "Invalid websocket token payload.",
356 catch: (cause) =>359 cause,
357 new SessionCredentialError({360 }),
358 message: "Invalid websocket token payload.",361 ),
359 cause,362 );
360 }),
361 });
362363
363 const now = yield* Clock.currentTimeMillis;364 const now = yield* Clock.currentTimeMillis;
364 if (claims.exp <= now) {365 if (claims.exp <= now) {
apps/server/src/cli-config.test.ts+64
...@@ -83,6 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -83,6 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
83 staticDir: undefined,83 staticDir: undefined,
84 devUrl: new URL("http://127.0.0.1:5173"),84 devUrl: new URL("http://127.0.0.1:5173"),
85 noBrowser: true,85 noBrowser: true,
86 startupPresentation: "browser",
86 desktopBootstrapToken: undefined,87 desktopBootstrapToken: undefined,
87 autoBootstrapProjectFromCwd: false,88 autoBootstrapProjectFromCwd: false,
88 logWebSocketEvents: true,89 logWebSocketEvents: true,
...@@ -144,6 +145,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -144,6 +145,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
144 staticDir: undefined,145 staticDir: undefined,
145 devUrl: new URL("http://127.0.0.1:4173"),146 devUrl: new URL("http://127.0.0.1:4173"),
146 noBrowser: true,147 noBrowser: true,
148 startupPresentation: "browser",
147 desktopBootstrapToken: undefined,149 desktopBootstrapToken: undefined,
148 autoBootstrapProjectFromCwd: true,150 autoBootstrapProjectFromCwd: true,
149 logWebSocketEvents: true,151 logWebSocketEvents: true,
...@@ -212,6 +214,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -212,6 +214,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
212 staticDir: undefined,214 staticDir: undefined,
213 devUrl: new URL("http://127.0.0.1:5173"),215 devUrl: new URL("http://127.0.0.1:5173"),
214 noBrowser: true,216 noBrowser: true,
217 startupPresentation: "browser",
215 desktopBootstrapToken: undefined,218 desktopBootstrapToken: undefined,
216 autoBootstrapProjectFromCwd: false,219 autoBootstrapProjectFromCwd: false,
217 logWebSocketEvents: true,220 logWebSocketEvents: true,
...@@ -329,6 +332,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -329,6 +332,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
329 staticDir: undefined,332 staticDir: undefined,
330 devUrl: new URL("http://127.0.0.1:4173"),333 devUrl: new URL("http://127.0.0.1:4173"),
331 noBrowser: true,334 noBrowser: true,
335 startupPresentation: "browser",
332 desktopBootstrapToken: undefined,336 desktopBootstrapToken: undefined,
333 autoBootstrapProjectFromCwd: true,337 autoBootstrapProjectFromCwd: true,
334 logWebSocketEvents: true,338 logWebSocketEvents: true,
...@@ -392,6 +396,66 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -392,6 +396,66 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
392 staticDir: resolved.staticDir,396 staticDir: resolved.staticDir,
393 devUrl: undefined,397 devUrl: undefined,
394 noBrowser: true,398 noBrowser: true,
399 startupPresentation: "browser",
400 desktopBootstrapToken: undefined,
401 autoBootstrapProjectFromCwd: false,
402 logWebSocketEvents: false,
403 });
404 }),
405 );
406
407 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
408 Effect.gen(function* () {
409 const { join } = yield* Path.Path;
410 const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
411 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
412
413 const resolved = yield* resolveServerConfig(
414 {
415 mode: Option.some("web"),
416 port: Option.some(3773),
417 host: Option.none(),
418 baseDir: Option.some(baseDir),
419 cwd: Option.none(),
420 devUrl: Option.none(),
421 noBrowser: Option.none(),
422 bootstrapFd: Option.none(),
423 autoBootstrapProjectFromCwd: Option.none(),
424 logWebSocketEvents: Option.none(),
425 },
426 Option.none(),
427 {
428 startupPresentation: "headless",
429 },
430 ).pipe(
431 Effect.provide(
432 Layer.mergeAll(
433 ConfigProvider.layer(
434 ConfigProvider.fromEnv({
435 env: {
436 T3CODE_NO_BROWSER: "false",
437 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
438 },
439 }),
440 ),
441 NetService.layer,
442 ),
443 ),
444 );
445
446 expect(resolved).toEqual({
447 logLevel: "Info",
448 ...defaultObservabilityConfig,
449 mode: "web",
450 port: 3773,
451 cwd: process.cwd(),
452 baseDir,
453 ...derivedPaths,
454 host: undefined,
455 staticDir: resolved.staticDir,
456 devUrl: undefined,
457 noBrowser: true,
458 startupPresentation: "headless",
395 desktopBootstrapToken: undefined,459 desktopBootstrapToken: undefined,
396 autoBootstrapProjectFromCwd: false,460 autoBootstrapProjectFromCwd: false,
397 logWebSocketEvents: false,461 logWebSocketEvents: false,
apps/server/src/cli.test.ts+219
...@@ -1,17 +1,38 @@...@@ -1,17 +1,38 @@
1import * as NodeHttp from "node:http";
1import { mkdtempSync } from "node:fs";2import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";3import { tmpdir } from "node:os";
3import { join } from "node:path";4import { join } from "node:path";
45
6import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
5import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
6import { NetService } from "@t3tools/shared/Net";8import { NetService } from "@t3tools/shared/Net";
7import { assert, it } from "@effect/vitest";9import { assert, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
12import * as HttpRouter from "effect/unstable/http/HttpRouter";
13import * as HttpServer from "effect/unstable/http/HttpServer";
10import * as CliError from "effect/unstable/cli/CliError";14import * as CliError from "effect/unstable/cli/CliError";
11import * as TestConsole from "effect/testing/TestConsole";15import * as TestConsole from "effect/testing/TestConsole";
12import { Command } from "effect/unstable/cli";16import { Command } from "effect/unstable/cli";
1317
14import { cli } from "./cli.ts";18import { cli } from "./cli.ts";
19import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
21import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
22import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
23import {
24 orchestrationDispatchRouteLayer,
25 orchestrationSnapshotRouteLayer,
26} from "./orchestration/http.ts";
27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
28import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
29import {
30 makePersistedServerRuntimeState,
31 persistServerRuntimeState,
32} from "./serverRuntimeState.ts";
33import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
34import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
35import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
1536
16const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);37const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1738
...@@ -28,6 +49,104 @@ const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>...@@ -28,6 +49,104 @@ const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
28 return { result, output };49 return { result, output };
29 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)));50 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)));
3051
52const makeCliTestServerConfig = (baseDir: string) =>
53 Effect.gen(function* () {
54 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
55 return {
56 logLevel: "Info",
57 traceMinLevel: "Info",
58 traceTimingEnabled: true,
59 traceBatchWindowMs: 200,
60 traceMaxBytes: 10 * 1024 * 1024,
61 traceMaxFiles: 10,
62 otlpTracesUrl: undefined,
63 otlpMetricsUrl: undefined,
64 otlpExportIntervalMs: 10_000,
65 otlpServiceName: "t3-server",
66 mode: "web",
67 port: 0,
68 host: "127.0.0.1",
69 cwd: process.cwd(),
70 baseDir,
71 ...derivedPaths,
72 staticDir: undefined,
73 devUrl: undefined,
74 noBrowser: true,
75 startupPresentation: "browser",
76 desktopBootstrapToken: undefined,
77 autoBootstrapProjectFromCwd: false,
78 logWebSocketEvents: false,
79 } satisfies ServerConfigShape;
80 });
81
82const makeProjectPersistenceLayer = (config: ServerConfigShape) =>
83 Layer.mergeAll(
84 OrchestrationLayerLive.pipe(
85 Layer.provideMerge(RepositoryIdentityResolverLive),
86 Layer.provideMerge(SqlitePersistenceLayerLive),
87 ),
88 WorkspacePathsLive,
89 ).pipe(
90 Layer.provideMerge(NodeServices.layer),
91 Layer.provide(Layer.succeed(ServerConfig, config)),
92 );
93
94const readPersistedSnapshot = (baseDir: string) =>
95 Effect.gen(function* () {
96 const config = yield* makeCliTestServerConfig(baseDir);
97 return yield* Effect.gen(function* () {
98 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
99 return yield* projectionSnapshotQuery.getSnapshot();
100 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
101 });
102
103const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
104 Effect.gen(function* () {
105 const config = yield* makeCliTestServerConfig(baseDir);
106 const routesLayer = Layer.mergeAll(
107 orchestrationSnapshotRouteLayer,
108 orchestrationDispatchRouteLayer,
109 );
110 const appLayer = HttpRouter.serve(routesLayer, {
111 disableListenLog: true,
112 disableLogger: true,
113 }).pipe(
114 Layer.provideMerge(
115 ServerAuthLive.pipe(
116 Layer.provideMerge(SqlitePersistenceLayerLive),
117 Layer.provide(ServerSecretStoreLive),
118 ),
119 ),
120 Layer.provideMerge(makeProjectPersistenceLayer(config)),
121 Layer.provideMerge(
122 NodeHttpServer.layer(NodeHttp.createServer, {
123 host: "127.0.0.1",
124 port: 0,
125 }),
126 ),
127 Layer.provideMerge(NodeServices.layer),
128 Layer.provide(Layer.succeed(ServerConfig, config)),
129 );
130
131 return yield* Effect.scoped(
132 Effect.gen(function* () {
133 const server = yield* HttpServer.HttpServer;
134 const address = server.address;
135 if (typeof address === "string" || !("port" in address)) {
136 assert.fail(`Expected TCP address, got ${address}`);
137 }
138 yield* persistServerRuntimeState({
139 path: config.serverRuntimeStatePath,
140 state: makePersistedServerRuntimeState({
141 config,
142 port: address.port,
143 }),
144 });
145 return yield* run();
146 }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))),
147 );
148 });
149
31it.layer(NodeServices.layer)("cli log-level parsing", (it) => {150it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
32 it.effect("accepts the built-in lowercase log-level flag values", () =>151 it.effect("accepts the built-in lowercase log-level flag values", () =>
33 runCliWithRuntime(["--log-level", "debug", "--version"]),152 runCliWithRuntime(["--log-level", "debug", "--version"]),
...@@ -130,4 +249,104 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => {...@@ -130,4 +249,104 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
130 assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes"));249 assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes"));
131 }),250 }),
132 );251 );
252
253 it.effect("adds, renames, and removes projects offline through the orchestration engine", () =>
254 Effect.gen(function* () {
255 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-"));
256 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-"));
257
258 yield* runCliWithRuntime([
259 "project",
260 "add",
261 workspaceRoot,
262 "--title",
263 "Alpha",
264 "--base-dir",
265 baseDir,
266 ]);
267 const afterAdd = yield* readPersistedSnapshot(baseDir);
268 const addedProject = afterAdd.projects.find(
269 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
270 );
271 assert.isTrue(addedProject !== undefined);
272 assert.equal(addedProject?.title, "Alpha");
273
274 yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
275 const afterRename = yield* readPersistedSnapshot(baseDir);
276 const renamedProject = afterRename.projects.find(
277 (project) => project.id === addedProject?.id,
278 );
279 assert.equal(renamedProject?.title, "Beta");
280 assert.equal(renamedProject?.deletedAt, null);
281
282 yield* runCliWithRuntime([
283 "project",
284 "remove",
285 addedProject?.id ?? "",
286 "--base-dir",
287 baseDir,
288 ]);
289 const afterRemove = yield* readPersistedSnapshot(baseDir);
290 const removedProject = afterRemove.projects.find(
291 (project) => project.id === addedProject?.id,
292 );
293 assert.isTrue((removedProject?.deletedAt ?? null) !== null);
294 }),
295 );
296
297 it.effect("routes project commands through a running server when runtime state is present", () =>
298 Effect.gen(function* () {
299 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-"));
300 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-"));
301
302 yield* withLiveProjectCliServer(baseDir, () =>
303 Effect.gen(function* () {
304 yield* runCliWithRuntime([
305 "project",
306 "add",
307 workspaceRoot,
308 "--title",
309 "Live Project",
310 "--base-dir",
311 baseDir,
312 ]);
313 const orchestrationEngine = yield* OrchestrationEngineService;
314 const readModel = yield* orchestrationEngine.getReadModel();
315 const addedProject = readModel.projects.find(
316 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
317 );
318 assert.isTrue(addedProject !== undefined);
319 assert.equal(addedProject?.title, "Live Project");
320 }),
321 );
322 }),
323 );
324
325 it.effect("rejects dev-url on project commands", () =>
326 Effect.gen(function* () {
327 const workspaceRoot = mkdtempSync(
328 join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"),
329 );
330 const error = yield* runCliWithRuntime([
331 "project",
332 "add",
333 workspaceRoot,
334 "--dev-url",
335 "http://127.0.0.1:5173",
336 ]).pipe(Effect.flip);
337
338 if (!CliError.isCliError(error)) {
339 assert.fail(`Expected CliError, got ${String(error)}`);
340 }
341 if (error._tag !== "ShowHelp") {
342 assert.fail(`Expected ShowHelp, got ${error._tag}`);
343 }
344 assert.deepEqual(error.commandPath, ["t3", "project", "add"]);
345 const optionError = error.errors[0] as CliError.CliError | undefined;
346 if (!optionError || optionError._tag !== "UnrecognizedOption") {
347 assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`);
348 }
349 assert.equal(optionError.option, "--dev-url");
350 }),
351 );
133});352});
apps/server/src/cli.ts+521-56
...@@ -1,12 +1,20 @@...@@ -1,12 +1,20 @@
1import { NetService } from "@t3tools/shared/Net";1import { NetService } from "@t3tools/shared/Net";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import { AuthSessionId } from "@t3tools/contracts";3import {
4 AuthSessionId,
5 CommandId,
6 OrchestrationReadModel,
7 ProjectId,
8 type ClientOrchestrationCommand,
9} from "@t3tools/contracts";
4import {10import {
5 Config,11 Config,
6 Console,12 Console,
7 Duration,13 Duration,
8 Effect,14 Effect,
15 Exit,
9 FileSystem,16 FileSystem,
17 Layer,
10 LogLevel,18 LogLevel,
11 Option,19 Option,
12 Path,20 Path,
...@@ -16,6 +24,12 @@ import {...@@ -16,6 +24,12 @@ import {
16 SchemaTransformation,24 SchemaTransformation,
17} from "effect";25} from "effect";
18import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";26import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
27import {
28 FetchHttpClient,
29 HttpClient,
30 HttpClientRequest,
31 HttpClientResponse,
32} from "effect/unstable/http";
1933
20import {34import {
21 DEFAULT_PORT,35 DEFAULT_PORT,
...@@ -25,6 +39,7 @@ import {...@@ -25,6 +39,7 @@ import {
25 ServerConfig,39 ServerConfig,
26 RuntimeMode,40 RuntimeMode,
27 type ServerConfigShape,41 type ServerConfigShape,
42 type StartupPresentation,
28} from "./config";43} from "./config";
29import { readBootstrapEnvelope } from "./bootstrap";44import { readBootstrapEnvelope } from "./bootstrap";
30import { expandHomePath, resolveBaseDir } from "./os-jank";45import { expandHomePath, resolveBaseDir } from "./os-jank";
...@@ -37,6 +52,18 @@ import {...@@ -37,6 +52,18 @@ import {
37 formatSessionList,52 formatSessionList,
38} from "./cliAuthFormat";53} from "./cliAuthFormat";
39import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts";54import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts";
55import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
56import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
57import { OrchestrationLayerLive } from "./orchestration/runtimeLayer";
58import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
59import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
60import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup";
61import {
62 clearPersistedServerRuntimeState,
63 readPersistedServerRuntimeState,
64} from "./serverRuntimeState";
65import { WorkspacePaths } from "./workspace/Services/WorkspacePaths";
66import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
4067
41const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));68const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
4269
...@@ -163,7 +190,7 @@ interface CliServerFlags {...@@ -163,7 +190,7 @@ interface CliServerFlags {
163190
164interface CliAuthLocationFlags {191interface CliAuthLocationFlags {
165 readonly baseDir: Option.Option<string>;192 readonly baseDir: Option.Option<string>;
166 readonly devUrl: Option.Option<URL>;193 readonly devUrl?: Option.Option<URL>;
167}194}
168195
169const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>196const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
...@@ -187,13 +214,29 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st...@@ -187,13 +214,29 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st
187export const resolveServerConfig = (214export const resolveServerConfig = (
188 flags: CliServerFlags,215 flags: CliServerFlags,
189 cliLogLevel: Option.Option<LogLevel.LogLevel>,216 cliLogLevel: Option.Option<LogLevel.LogLevel>,
217 options?: {
218 readonly startupPresentation?: StartupPresentation;
219 readonly forceAutoBootstrapProjectFromCwd?: boolean;
220 },
190) =>221) =>
191 Effect.gen(function* () {222 Effect.gen(function* () {
192 const { findAvailablePort } = yield* NetService;223 const { findAvailablePort } = yield* NetService;
193 const path = yield* Path.Path;224 const path = yield* Path.Path;
194 const fs = yield* FileSystem.FileSystem;225 const fs = yield* FileSystem.FileSystem;
195 const env = yield* EnvServerConfig;226 const env = yield* EnvServerConfig;
196 const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd;227 const normalizedFlags = {
228 mode: flags.mode ?? Option.none(),
229 port: flags.port ?? Option.none(),
230 host: flags.host ?? Option.none(),
231 baseDir: flags.baseDir ?? Option.none(),
232 cwd: flags.cwd ?? Option.none(),
233 devUrl: flags.devUrl ?? Option.none(),
234 noBrowser: flags.noBrowser ?? Option.none(),
235 bootstrapFd: flags.bootstrapFd ?? Option.none(),
236 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
237 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
238 } satisfies CliServerFlags;
239 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
197 const bootstrapEnvelope =240 const bootstrapEnvelope =
198 bootstrapFd !== undefined241 bootstrapFd !== undefined
199 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)242 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
...@@ -201,7 +244,7 @@ export const resolveServerConfig = (...@@ -201,7 +244,7 @@ export const resolveServerConfig = (
201244
202 const mode: RuntimeMode = Option.getOrElse(245 const mode: RuntimeMode = Option.getOrElse(
203 resolveOptionPrecedence(246 resolveOptionPrecedence(
204 flags.mode,247 normalizedFlags.mode,
205 Option.fromUndefinedOr(env.mode),248 Option.fromUndefinedOr(env.mode),
206 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)),249 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)),
207 ),250 ),
...@@ -210,7 +253,7 @@ export const resolveServerConfig = (...@@ -210,7 +253,7 @@ export const resolveServerConfig = (
210253
211 const port = yield* Option.match(254 const port = yield* Option.match(
212 resolveOptionPrecedence(255 resolveOptionPrecedence(
213 flags.port,256 normalizedFlags.port,
214 Option.fromUndefinedOr(env.port),257 Option.fromUndefinedOr(env.port),
215 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)),258 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)),
216 ),259 ),
...@@ -226,7 +269,7 @@ export const resolveServerConfig = (...@@ -226,7 +269,7 @@ export const resolveServerConfig = (
226 );269 );
227 const devUrl = Option.getOrElse(270 const devUrl = Option.getOrElse(
228 resolveOptionPrecedence(271 resolveOptionPrecedence(
229 flags.devUrl,272 normalizedFlags.devUrl,
230 Option.fromUndefinedOr(env.devUrl),273 Option.fromUndefinedOr(env.devUrl),
231 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)),274 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)),
232 ),275 ),
...@@ -235,7 +278,7 @@ export const resolveServerConfig = (...@@ -235,7 +278,7 @@ export const resolveServerConfig = (
235 const baseDir = yield* resolveBaseDir(278 const baseDir = yield* resolveBaseDir(
236 Option.getOrUndefined(279 Option.getOrUndefined(
237 resolveOptionPrecedence(280 resolveOptionPrecedence(
238 flags.baseDir,281 normalizedFlags.baseDir,
239 Option.fromUndefinedOr(env.t3Home),282 Option.fromUndefinedOr(env.t3Home),
240 Option.flatMap(bootstrapEnvelope, (bootstrap) =>283 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
241 Option.fromUndefinedOr(bootstrap.t3Home),284 Option.fromUndefinedOr(bootstrap.t3Home),
...@@ -243,7 +286,7 @@ export const resolveServerConfig = (...@@ -243,7 +286,7 @@ export const resolveServerConfig = (
243 ),286 ),
244 ),287 ),
245 );288 );
246 const rawCwd = Option.getOrElse(flags.cwd, () => process.cwd());289 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
247 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));290 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
248 yield* fs.makeDirectory(cwd, { recursive: true });291 yield* fs.makeDirectory(cwd, { recursive: true });
249 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);292 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
...@@ -253,37 +296,45 @@ export const resolveServerConfig = (...@@ -253,37 +296,45 @@ export const resolveServerConfig = (
253 );296 );
254 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;297 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
255 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });298 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
256 const noBrowser = resolveBooleanFlag(299 const startupPresentation = options?.startupPresentation ?? "browser";
257 flags.noBrowser,300 const noBrowser =
258 Option.getOrElse(301 startupPresentation === "headless"
259 resolveOptionPrecedence(302 ? true
260 Option.fromUndefinedOr(env.noBrowser),303 : resolveBooleanFlag(
261 Option.flatMap(bootstrapEnvelope, (bootstrap) =>304 normalizedFlags.noBrowser,
262 Option.fromUndefinedOr(bootstrap.noBrowser),305 Option.getOrElse(
263 ),306 resolveOptionPrecedence(
264 ),307 Option.fromUndefinedOr(env.noBrowser),
265 () => mode === "desktop",308 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
266 ),309 Option.fromUndefinedOr(bootstrap.noBrowser),
267 );310 ),
311 ),
312 () => mode === "desktop",
313 ),
314 );
268 const desktopBootstrapToken = Option.getOrUndefined(315 const desktopBootstrapToken = Option.getOrUndefined(
269 Option.flatMap(bootstrapEnvelope, (bootstrap) =>316 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
270 Option.fromUndefinedOr(bootstrap.desktopBootstrapToken),317 Option.fromUndefinedOr(bootstrap.desktopBootstrapToken),
271 ),318 ),
272 );319 );
273 const autoBootstrapProjectFromCwd = resolveBooleanFlag(320 const autoBootstrapProjectFromCwd =
274 flags.autoBootstrapProjectFromCwd,321 options?.forceAutoBootstrapProjectFromCwd ??
275 Option.getOrElse(322 (startupPresentation === "headless"
276 resolveOptionPrecedence(323 ? false
277 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),324 : resolveBooleanFlag(
278 Option.flatMap(bootstrapEnvelope, (bootstrap) =>325 normalizedFlags.autoBootstrapProjectFromCwd,
279 Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),326 Option.getOrElse(
280 ),327 resolveOptionPrecedence(
281 ),328 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
282 () => mode === "web",329 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
283 ),330 Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
284 );331 ),
332 ),
333 () => mode === "web",
334 ),
335 ));
285 const logWebSocketEvents = resolveBooleanFlag(336 const logWebSocketEvents = resolveBooleanFlag(
286 flags.logWebSocketEvents,337 normalizedFlags.logWebSocketEvents,
287 Option.getOrElse(338 Option.getOrElse(
288 resolveOptionPrecedence(339 resolveOptionPrecedence(
289 Option.fromUndefinedOr(env.logWebSocketEvents),340 Option.fromUndefinedOr(env.logWebSocketEvents),
...@@ -297,7 +348,7 @@ export const resolveServerConfig = (...@@ -297,7 +348,7 @@ export const resolveServerConfig = (
297 const staticDir = devUrl ? undefined : yield* resolveStaticDir();348 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
298 const host = Option.getOrElse(349 const host = Option.getOrElse(
299 resolveOptionPrecedence(350 resolveOptionPrecedence(
300 flags.host,351 normalizedFlags.host,
301 Option.fromUndefinedOr(env.host),352 Option.fromUndefinedOr(env.host),
302 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),353 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
303 ),354 ),
...@@ -340,6 +391,7 @@ export const resolveServerConfig = (...@@ -340,6 +391,7 @@ export const resolveServerConfig = (
340 staticDir,391 staticDir,
341 devUrl,392 devUrl,
342 noBrowser,393 noBrowser,
394 startupPresentation,
343 desktopBootstrapToken,395 desktopBootstrapToken,
344 autoBootstrapProjectFromCwd,396 autoBootstrapProjectFromCwd,
345 logWebSocketEvents,397 logWebSocketEvents,
...@@ -359,7 +411,7 @@ const resolveCliAuthConfig = (...@@ -359,7 +411,7 @@ const resolveCliAuthConfig = (
359 host: Option.none(),411 host: Option.none(),
360 baseDir: flags.baseDir,412 baseDir: flags.baseDir,
361 cwd: Option.none(),413 cwd: Option.none(),
362 devUrl: flags.devUrl,414 devUrl: flags.devUrl ?? Option.none(),
363 noBrowser: Option.none(),415 noBrowser: Option.none(),
364 bootstrapFd: Option.none(),416 bootstrapFd: Option.none(),
365 autoBootstrapProjectFromCwd: Option.none(),417 autoBootstrapProjectFromCwd: Option.none(),
...@@ -446,13 +498,289 @@ const runWithAuthControlPlane = <A, E>(...@@ -446,13 +498,289 @@ const runWithAuthControlPlane = <A, E>(
446 const authControlPlane = yield* AuthControlPlane;498 const authControlPlane = yield* AuthControlPlane;
447 return yield* run(authControlPlane);499 return yield* run(authControlPlane);
448 }).pipe(500 }).pipe(
449 Effect.provide(AuthControlPlaneRuntimeLive),501 Effect.provide(
450 Effect.provideService(ServerConfig, config),502 Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(
451 Effect.provideService(References.MinimumLogLevel, minimumLogLevel),503 Layer.provide(Layer.succeed(ServerConfig, config)),
504 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
505 ),
506 ),
452 );507 );
453 });508 });
454509
455const commandFlags = {510type ProjectMutationTarget = {
511 readonly id: ProjectId;
512 readonly title: string;
513 readonly workspaceRoot: string;
514};
515
516type ProjectCommandExecutionMode = "live" | "offline";
517type ProjectCliDispatchCommand = Extract<
518 ClientOrchestrationCommand,
519 { type: "project.create" | "project.meta.update" | "project.delete" }
520>;
521
522const ProjectCliRuntimeLive = Layer.mergeAll(
523 WorkspacePathsLive,
524 OrchestrationLayerLive.pipe(
525 Layer.provideMerge(RepositoryIdentityResolverLive),
526 Layer.provideMerge(SqlitePersistenceLayerLive),
527 ),
528);
529
530const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
531const OrchestrationHttpErrorResponse = Schema.Struct({
532 error: Schema.String,
533});
534
535const withProjectCliSessionToken = <A, E, R>(
536 authControlPlane: AuthControlPlaneShape,
537 run: (token: string) => Effect.Effect<A, E, R>,
538) =>
539 Effect.acquireUseRelease(
540 authControlPlane.issueSession({
541 role: "owner",
542 label: "t3 project cli",
543 }),
544 (issued) => run(issued.token),
545 (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
546 );
547
548const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
549 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
550
551const runLiveServerRequest = <A, E extends Error, R>(
552 request: HttpClientRequest.HttpClientRequest,
553 handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
554) =>
555 Effect.gen(function* () {
556 const httpClient = yield* HttpClient.HttpClient;
557 const response = yield* httpClient.execute(request);
558 return yield* handle(response);
559 }).pipe(withProjectCliLiveServerTimeout);
560
561const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>
562 HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);
563
564const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>
565 HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(
566 Effect.map((body) => body.error),
567 Effect.catch(() => Effect.succeed(null)),
568 Effect.map((body) => {
569 if (typeof body === "string" && body.trim().length > 0) {
570 return body;
571 }
572 return `Server request failed with status ${response.status}.`;
573 }),
574 );
575
576const normalizeWorkspaceRootForProjectCommand = Effect.fn(
577 "normalizeWorkspaceRootForProjectCommand",
578)(function* (workspaceRoot: string) {
579 const workspacePaths = yield* WorkspacePaths;
580 return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot);
581});
582
583const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
584 workspaceRoot: string,
585 explicitTitle?: string,
586) {
587 if (explicitTitle !== undefined) {
588 const trimmed = explicitTitle.trim();
589 if (trimmed.length > 0) {
590 return trimmed;
591 }
592 return yield* Effect.fail(new Error("Project title cannot be empty."));
593 }
594
595 const path = yield* Path.Path;
596 const basename = path.basename(workspaceRoot).trim();
597 return basename.length > 0 ? basename : "project";
598});
599
600const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: {
601 readonly snapshot: OrchestrationReadModel;
602 readonly identifier: string;
603}) {
604 const trimmedIdentifier = input.identifier.trim();
605 if (trimmedIdentifier.length === 0) {
606 return yield* Effect.fail(new Error("Project identifier cannot be empty."));
607 }
608
609 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
610 const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier);
611 if (exactIdMatch) {
612 return {
613 id: exactIdMatch.id,
614 title: exactIdMatch.title,
615 workspaceRoot: exactIdMatch.workspaceRoot,
616 } satisfies ProjectMutationTarget;
617 }
618
619 const normalizedWorkspaceRootResult = yield* Effect.exit(
620 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
621 );
622 const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)
623 ? normalizedWorkspaceRootResult.value
624 : null;
625
626 const exactWorkspaceMatch =
627 normalizedWorkspaceRoot === null
628 ? undefined
629 : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot);
630
631 const resolved = exactWorkspaceMatch;
632 if (!resolved) {
633 return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`));
634 }
635
636 return {
637 id: resolved.id,
638 title: resolved.title,
639 workspaceRoot: resolved.workspaceRoot,
640 } satisfies ProjectMutationTarget;
641});
642
643const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
644 runLiveServerRequest(
645 HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(
646 HttpClientRequest.acceptJson,
647 HttpClientRequest.bearerToken(bearerToken),
648 ),
649 HttpClientResponse.matchStatus({
650 "2xx": decodeOrchestrationReadModelResponse,
651 orElse: (response) =>
652 readErrorMessageFromResponse(response).pipe(
653 Effect.flatMap((message) => Effect.fail(new Error(message))),
654 ),
655 }),
656 );
657
658const dispatchLiveOrchestrationCommand = (
659 origin: string,
660 bearerToken: string,
661 command: ProjectCliDispatchCommand,
662) =>
663 HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(
664 HttpClientRequest.acceptJson,
665 HttpClientRequest.bearerToken(bearerToken),
666 HttpClientRequest.bodyJson(command),
667 Effect.flatMap((request) =>
668 runLiveServerRequest(
669 request,
670 HttpClientResponse.matchStatus({
671 "2xx": () => Effect.void,
672 orElse: (response) =>
673 readErrorMessageFromResponse(response).pipe(
674 Effect.flatMap((message) => Effect.fail(new Error(message))),
675 ),
676 }),
677 ),
678 ),
679 );
680
681const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
682 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
683 return yield* projectionSnapshotQuery.getSnapshot();
684});
685
686const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
687 function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {
688 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
689 if (Option.isNone(runtimeState)) {
690 return Option.none<{ readonly origin: string }>();
691 }
692
693 const attempt = withProjectCliSessionToken(authControlPlane, (token) =>
694 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
695 Effect.as({
696 origin: runtimeState.value.origin,
697 }),
698 ),
699 );
700
701 const attempted = yield* Effect.exit(attempt);
702 if (Exit.isSuccess(attempted)) {
703 return Option.some(attempted.value);
704 }
705
706 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
707 return Option.none<{ readonly origin: string }>();
708 },
709);
710
711const runProjectMutation = Effect.fn("runProjectMutation")(function* (
712 flags: CliAuthLocationFlags,
713 run: (input: {
714 readonly snapshot: OrchestrationReadModel;
715 readonly dispatch: (
716 command: ProjectCliDispatchCommand,
717 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
718 readonly mode: ProjectCommandExecutionMode;
719 }) => Effect.Effect<
720 string,
721 Error,
722 FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths
723 >,
724) {
725 const logLevel = yield* GlobalFlag.LogLevel;
726 const config = yield* resolveCliAuthConfig(flags, logLevel);
727 const minimumLogLevel = config.logLevel;
728
729 return yield* Effect.gen(function* () {
730 const authControlPlane = yield* AuthControlPlane;
731 const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);
732
733 if (Option.isSome(liveMode)) {
734 return yield* withProjectCliSessionToken(authControlPlane, (token) =>
735 Effect.gen(function* () {
736 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
737 const output = yield* run({
738 snapshot,
739 dispatch: (command) =>
740 dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command),
741 mode: "live",
742 });
743 yield* Console.log(output);
744 }),
745 );
746 }
747
748 const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(
749 Layer.provide(Layer.succeed(ServerConfig, config)),
750 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
751 );
752
753 return yield* Effect.gen(function* () {
754 const snapshot = yield* getOfflineSnapshot();
755 const orchestrationEngine = yield* OrchestrationEngineService;
756 const output = yield* run({
757 snapshot,
758 dispatch: (command) => orchestrationEngine.dispatch(command),
759 mode: "offline",
760 });
761 yield* Console.log(output);
762 }).pipe(Effect.provide(offlineRuntimeLayer));
763 }).pipe(
764 Effect.provide(
765 Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(
766 Layer.provideMerge(FetchHttpClient.layer),
767 Layer.provide(Layer.succeed(ServerConfig, config)),
768 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
769 ),
770 ),
771 );
772});
773
774const sharedServerLocationFlags = {
775 baseDir: baseDirFlag,
776 devUrl: devUrlFlag,
777} as const;
778
779const projectLocationFlags = {
780 baseDir: baseDirFlag,
781} as const;
782
783const sharedServerCommandFlags = {
456 mode: modeFlag,784 mode: modeFlag,
457 port: portFlag,785 port: portFlag,
458 host: hostFlag,786 host: hostFlag,
...@@ -470,10 +798,7 @@ const commandFlags = {...@@ -470,10 +798,7 @@ const commandFlags = {
470 logWebSocketEvents: logWebSocketEventsFlag,798 logWebSocketEvents: logWebSocketEventsFlag,
471} as const;799} as const;
472800
473const authLocationFlags = {801const authLocationFlags = sharedServerLocationFlags;
474 baseDir: baseDirFlag,
475 devUrl: devUrlFlag,
476} as const;
477802
478const ttlFlag = Flag.string("ttl").pipe(803const ttlFlag = Flag.string("ttl").pipe(
479 Flag.withSchema(DurationFromString),804 Flag.withSchema(DurationFromString),
...@@ -674,25 +999,165 @@ const authCommand = Command.make("auth").pipe(...@@ -674,25 +999,165 @@ const authCommand = Command.make("auth").pipe(
674 Command.withSubcommands([pairingCommand, sessionCommand]),999 Command.withSubcommands([pairingCommand, sessionCommand]),
675);1000);
6761001
677const startCommand = Command.make("start", commandFlags).pipe(1002const projectAddCommand = Command.make("add", {
678 Command.withDescription("Run the T3 Code server."),1003 ...projectLocationFlags,
1004 workspaceRoot: Argument.string("path").pipe(
1005 Argument.withDescription("Workspace root to add as a project."),
1006 ),
1007 title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional),
1008}).pipe(
1009 Command.withDescription("Add a project."),
679 Command.withHandler((flags) =>1010 Command.withHandler((flags) =>
680 Effect.gen(function* () {1011 runProjectMutation(
681 const logLevel = yield* GlobalFlag.LogLevel;1012 flags,
682 const config = yield* resolveServerConfig(flags, logLevel);1013 Effect.fn("projectAddMutation")(function* ({
683 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));1014 snapshot,
684 }),1015 dispatch,
1016 }: {
1017 readonly snapshot: OrchestrationReadModel;
1018 readonly dispatch: (
1019 command: ProjectCliDispatchCommand,
1020 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1021 }) {
1022 const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
1023 const existingProject = snapshot.projects.find(
1024 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
1025 );
1026 if (existingProject) {
1027 return yield* Effect.fail(
1028 new Error(`An active project already exists for '${workspaceRoot}'.`),
1029 );
1030 }
1031
1032 const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title));
1033 const projectId = ProjectId.makeUnsafe(crypto.randomUUID());
1034 yield* dispatch({
1035 type: "project.create",
1036 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
1037 projectId,
1038 title,
1039 workspaceRoot,
1040 defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
1041 createdAt: new Date().toISOString(),
1042 });
1043 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
1044 }),
1045 ),
1046 ),
1047);
1048
1049const projectRemoveCommand = Command.make("remove", {
1050 ...projectLocationFlags,
1051 project: Argument.string("project").pipe(
1052 Argument.withDescription("Project id or workspace root to remove."),
1053 ),
1054}).pipe(
1055 Command.withDescription("Remove a project."),
1056 Command.withHandler((flags) =>
1057 runProjectMutation(
1058 flags,
1059 Effect.fn("projectRemoveMutation")(function* ({
1060 snapshot,
1061 dispatch,
1062 }: {
1063 readonly snapshot: OrchestrationReadModel;
1064 readonly dispatch: (
1065 command: ProjectCliDispatchCommand,
1066 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1067 }) {
1068 const project = yield* findActiveProjectTarget({
1069 snapshot,
1070 identifier: flags.project,
1071 });
1072 yield* dispatch({
1073 type: "project.delete",
1074 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
1075 projectId: project.id,
1076 });
1077 return `Removed project ${project.id} (${project.title}).`;
1078 }),
1079 ),
1080 ),
1081);
1082
1083const projectRenameCommand = Command.make("rename", {
1084 ...projectLocationFlags,
1085 project: Argument.string("project").pipe(
1086 Argument.withDescription("Project id or workspace root to rename."),
1087 ),
1088 title: Argument.string("title").pipe(Argument.withDescription("New project title.")),
1089}).pipe(
1090 Command.withDescription("Rename a project."),
1091 Command.withHandler((flags) =>
1092 runProjectMutation(
1093 flags,
1094 Effect.fn("projectRenameMutation")(function* ({
1095 snapshot,
1096 dispatch,
1097 }: {
1098 readonly snapshot: OrchestrationReadModel;
1099 readonly dispatch: (
1100 command: ProjectCliDispatchCommand,
1101 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1102 }) {
1103 const project = yield* findActiveProjectTarget({
1104 snapshot,
1105 identifier: flags.project,
1106 });
1107 const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title);
1108 if (nextTitle === project.title) {
1109 return `Project ${project.id} is already named ${nextTitle}.`;
1110 }
1111
1112 yield* dispatch({
1113 type: "project.meta.update",
1114 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
1115 projectId: project.id,
1116 title: nextTitle,
1117 });
1118 return `Renamed project ${project.id} to ${nextTitle}.`;
1119 }),
1120 ),
685 ),1121 ),
686);1122);
6871123
688export const cli = Command.make("t3", commandFlags).pipe(1124const projectCommand = Command.make("project").pipe(
1125 Command.withDescription("Manage projects."),
1126 Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
1127);
1128
1129const runServerCommand = (
1130 flags: CliServerFlags,
1131 options?: {
1132 readonly startupPresentation?: StartupPresentation;
1133 readonly forceAutoBootstrapProjectFromCwd?: boolean;
1134 },
1135) =>
1136 Effect.gen(function* () {
1137 const logLevel = yield* GlobalFlag.LogLevel;
1138 const config = yield* resolveServerConfig(flags, logLevel, options);
1139 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1140 });
1141
1142const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
689 Command.withDescription("Run the T3 Code server."),1143 Command.withDescription("Run the T3 Code server."),
1144 Command.withHandler((flags) => runServerCommand(flags)),
1145);
1146
1147const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
1148 Command.withDescription(
1149 "Run the T3 Code server without opening a browser and print headless pairing details.",
1150 ),
690 Command.withHandler((flags) =>1151 Command.withHandler((flags) =>
691 Effect.gen(function* () {1152 runServerCommand(flags, {
692 const logLevel = yield* GlobalFlag.LogLevel;1153 startupPresentation: "headless",
693 const config = yield* resolveServerConfig(flags, logLevel);1154 forceAutoBootstrapProjectFromCwd: false,
694 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
695 }),1155 }),
696 ),1156 ),
697 Command.withSubcommands([startCommand, authCommand]),1157);
1158
1159export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
1160 Command.withDescription("Run the T3 Code server."),
1161 Command.withHandler((flags) => runServerCommand(flags)),
1162 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
698);1163);
apps/server/src/config.ts+8
...@@ -13,6 +13,9 @@ export const DEFAULT_PORT = 3773;...@@ -13,6 +13,9 @@ export const DEFAULT_PORT = 3773;
13export const RuntimeMode = Schema.Literals(["web", "desktop"]);13export const RuntimeMode = Schema.Literals(["web", "desktop"]);
14export type RuntimeMode = typeof RuntimeMode.Type;14export type RuntimeMode = typeof RuntimeMode.Type;
1515
16export const StartupPresentation = Schema.Literals(["browser", "headless"]);
17export type StartupPresentation = typeof StartupPresentation.Type;
18
16/**19/**
17 * ServerDerivedPaths - Derived paths from the base directory.20 * ServerDerivedPaths - Derived paths from the base directory.
18 */21 */
...@@ -31,6 +34,7 @@ export interface ServerDerivedPaths {...@@ -31,6 +34,7 @@ export interface ServerDerivedPaths {
31 readonly terminalLogsDir: string;34 readonly terminalLogsDir: string;
32 readonly anonymousIdPath: string;35 readonly anonymousIdPath: string;
33 readonly environmentIdPath: string;36 readonly environmentIdPath: string;
37 readonly serverRuntimeStatePath: string;
34 readonly secretsDir: string;38 readonly secretsDir: string;
35}39}
3640
...@@ -56,6 +60,7 @@ export interface ServerConfigShape extends ServerDerivedPaths {...@@ -56,6 +60,7 @@ export interface ServerConfigShape extends ServerDerivedPaths {
56 readonly staticDir: string | undefined;60 readonly staticDir: string | undefined;
57 readonly devUrl: URL | undefined;61 readonly devUrl: URL | undefined;
58 readonly noBrowser: boolean;62 readonly noBrowser: boolean;
63 readonly startupPresentation: StartupPresentation;
59 readonly desktopBootstrapToken: string | undefined;64 readonly desktopBootstrapToken: string | undefined;
60 readonly autoBootstrapProjectFromCwd: boolean;65 readonly autoBootstrapProjectFromCwd: boolean;
61 readonly logWebSocketEvents: boolean;66 readonly logWebSocketEvents: boolean;
...@@ -86,6 +91,7 @@ export const deriveServerPaths = Effect.fn(function* (...@@ -86,6 +91,7 @@ export const deriveServerPaths = Effect.fn(function* (
86 terminalLogsDir: join(logsDir, "terminals"),91 terminalLogsDir: join(logsDir, "terminals"),
87 anonymousIdPath: join(stateDir, "anonymous-id"),92 anonymousIdPath: join(stateDir, "anonymous-id"),
88 environmentIdPath: join(stateDir, "environment-id"),93 environmentIdPath: join(stateDir, "environment-id"),
94 serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
89 secretsDir: join(stateDir, "secrets"),95 secretsDir: join(stateDir, "secrets"),
90 };96 };
91});97});
...@@ -105,6 +111,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server...@@ -105,6 +111,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
105 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),111 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
106 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),112 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
107 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),113 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
114 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
108 ],115 ],
109 { concurrency: "unbounded" },116 { concurrency: "unbounded" },
110 );117 );
...@@ -153,6 +160,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS...@@ -153,6 +160,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
153 staticDir: undefined,160 staticDir: undefined,
154 devUrl,161 devUrl,
155 noBrowser: false,162 noBrowser: false,
163 startupPresentation: "browser",
156 } satisfies ServerConfigShape;164 } satisfies ServerConfigShape;
157 }),165 }),
158 );166 );
apps/server/src/environment/Layers/ServerEnvironment.test.ts+1
...@@ -36,6 +36,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -36,6 +36,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
36 staticDir: undefined,36 staticDir: undefined,
37 devUrl: undefined,37 devUrl: undefined,
38 noBrowser: false,38 noBrowser: false,
39 startupPresentation: "browser",
39 } satisfies ServerConfigShape;40 } satisfies ServerConfigShape;
40});41});
4142
apps/server/src/orchestration/http.ts created+93
...@@ -0,0 +1,93 @@
1import {
2 ClientOrchestrationCommand,
3 OrchestrationDispatchCommandError,
4 OrchestrationGetSnapshotError,
5 type OrchestrationReadModel,
6} from "@t3tools/contracts";
7import { Effect } from "effect";
8import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
9
10import { ServerAuth } from "../auth/Services/ServerAuth.ts";
11import { normalizeDispatchCommand } from "./Normalizer.ts";
12import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
13import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
14
15const respondToOrchestrationHttpError = (
16 error: OrchestrationDispatchCommandError | OrchestrationGetSnapshotError,
17) =>
18 Effect.gen(function* () {
19 if (error._tag === "OrchestrationGetSnapshotError") {
20 yield* Effect.logError("orchestration http route failed", {
21 message: error.message,
22 cause: error.cause,
23 });
24 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 });
25 }
26
27 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 });
28 });
29
30const authenticateOwnerSession = Effect.gen(function* () {
31 const request = yield* HttpServerRequest.HttpServerRequest;
32 const serverAuth = yield* ServerAuth;
33 const session = yield* serverAuth.authenticateHttpRequest(request);
34 if (session.role !== "owner") {
35 return yield* new OrchestrationDispatchCommandError({
36 message: "Only owner sessions can manage projects.",
37 });
38 }
39 return session;
40});
41
42export const orchestrationSnapshotRouteLayer = HttpRouter.add(
43 "GET",
44 "/api/orchestration/snapshot",
45 Effect.gen(function* () {
46 yield* authenticateOwnerSession;
47 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
48 const snapshot = yield* projectionSnapshotQuery.getSnapshot().pipe(
49 Effect.mapError(
50 (cause) =>
51 new OrchestrationGetSnapshotError({
52 message: "Failed to load orchestration snapshot.",
53 cause,
54 }),
55 ),
56 );
57 return HttpServerResponse.jsonUnsafe(snapshot satisfies OrchestrationReadModel, {
58 status: 200,
59 });
60 }).pipe(
61 Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError),
62 Effect.catchTag("OrchestrationGetSnapshotError", respondToOrchestrationHttpError),
63 ),
64);
65
66export const orchestrationDispatchRouteLayer = HttpRouter.add(
67 "POST",
68 "/api/orchestration/dispatch",
69 Effect.gen(function* () {
70 yield* authenticateOwnerSession;
71 const orchestrationEngine = yield* OrchestrationEngineService;
72 const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe(
73 Effect.mapError(
74 (cause) =>
75 new OrchestrationDispatchCommandError({
76 message: "Invalid orchestration command payload.",
77 cause,
78 }),
79 ),
80 );
81 const normalizedCommand = yield* normalizeDispatchCommand(command);
82 const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe(
83 Effect.mapError(
84 (cause) =>
85 new OrchestrationDispatchCommandError({
86 message: "Failed to dispatch orchestration command.",
87 cause,
88 }),
89 ),
90 );
91 return HttpServerResponse.jsonUnsafe(result, { status: 200 });
92 }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)),
93);
apps/server/src/orchestration/runtimeLayer.ts created+27
...@@ -0,0 +1,27 @@
1import { Layer } from "effect";
2
3import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts";
4import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts";
5import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
6import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
7import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
8
9export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
10 OrchestrationEventStoreLive,
11 OrchestrationCommandReceiptRepositoryLive,
12);
13
14export const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe(
15 Layer.provide(OrchestrationEventStoreLive),
16);
17
18export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
19 OrchestrationProjectionSnapshotQueryLive,
20 OrchestrationEventInfrastructureLayerLive,
21 OrchestrationProjectionPipelineLayerLive,
22);
23
24export const OrchestrationLayerLive = Layer.mergeAll(
25 OrchestrationInfrastructureLayerLive,
26 OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)),
27);
apps/server/src/server.test.ts+1
...@@ -331,6 +331,7 @@ const buildAppUnderTest = (options?: {...@@ -331,6 +331,7 @@ const buildAppUnderTest = (options?: {
331 staticDir: undefined,331 staticDir: undefined,
332 devUrl,332 devUrl,
333 noBrowser: true,333 noBrowser: true,
334 startupPresentation: "browser",
334 desktopBootstrapToken: defaultDesktopBootstrapToken,335 desktopBootstrapToken: defaultDesktopBootstrapToken,
335 autoBootstrapProjectFromCwd: false,336 autoBootstrapProjectFromCwd: false,
336 logWebSocketEvents: false,337 logWebSocketEvents: false,
apps/server/src/server.ts+34-25
...@@ -23,12 +23,7 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";...@@ -23,12 +23,7 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
25import { makeProviderServiceLive } from "./provider/Layers/ProviderService";25import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
26import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
27import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
28import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
29import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
30import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";26import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
31import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
32import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";27import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
33import { GitCoreLive } from "./git/Layers/GitCore";28import { GitCoreLive } from "./git/Layers/GitCore";
34import { GitHubCliLive } from "./git/Layers/GitHubCli";29import { GitHubCliLive } from "./git/Layers/GitHubCli";
...@@ -67,6 +62,16 @@ import {...@@ -67,6 +62,16 @@ import {
67} from "./auth/http";62} from "./auth/http";
68import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore";63import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore";
69import { ServerAuthLive } from "./auth/Layers/ServerAuth";64import { ServerAuthLive } from "./auth/Layers/ServerAuth";
65import { OrchestrationLayerLive } from "./orchestration/runtimeLayer";
66import {
67 clearPersistedServerRuntimeState,
68 makePersistedServerRuntimeState,
69 persistServerRuntimeState,
70} from "./serverRuntimeState";
71import {
72 orchestrationDispatchRouteLayer,
73 orchestrationSnapshotRouteLayer,
74} from "./orchestration/http";
7075
71const PtyAdapterLive = Layer.unwrap(76const PtyAdapterLive = Layer.unwrap(
72 Effect.gen(function* () {77 Effect.gen(function* () {
...@@ -124,26 +129,6 @@ const ReactorLayerLive = Layer.empty.pipe(...@@ -124,26 +129,6 @@ const ReactorLayerLive = Layer.empty.pipe(
124 Layer.provideMerge(RuntimeReceiptBusLive),129 Layer.provideMerge(RuntimeReceiptBusLive),
125);130);
126131
127const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
128 OrchestrationEventStoreLive,
129 OrchestrationCommandReceiptRepositoryLive,
130);
131
132const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe(
133 Layer.provide(OrchestrationEventStoreLive),
134);
135
136const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
137 OrchestrationProjectionSnapshotQueryLive,
138 OrchestrationEventInfrastructureLayerLive,
139 OrchestrationProjectionPipelineLayerLive,
140);
141
142const OrchestrationLayerLive = Layer.mergeAll(
143 OrchestrationInfrastructureLayerLive,
144 OrchestrationEngineLive.pipe(Layer.provide(OrchestrationInfrastructureLayerLive)),
145);
146
147const CheckpointingLayerLive = Layer.empty.pipe(132const CheckpointingLayerLive = Layer.empty.pipe(
148 Layer.provideMerge(CheckpointDiffQueryLive),133 Layer.provideMerge(CheckpointDiffQueryLive),
149 Layer.provideMerge(CheckpointStoreLive),134 Layer.provideMerge(CheckpointStoreLive),
...@@ -248,6 +233,8 @@ export const makeRoutesLayer = Layer.mergeAll(...@@ -248,6 +233,8 @@ export const makeRoutesLayer = Layer.mergeAll(
248 authSessionRouteLayer,233 authSessionRouteLayer,
249 authWebSocketTokenRouteLayer,234 authWebSocketTokenRouteLayer,
250 attachmentsRouteLayer,235 attachmentsRouteLayer,
236 orchestrationDispatchRouteLayer,
237 orchestrationSnapshotRouteLayer,
251 otlpTracesProxyRouteLayer,238 otlpTracesProxyRouteLayer,
252 projectFaviconRouteLayer,239 projectFaviconRouteLayer,
253 serverEnvironmentRouteLayer,240 serverEnvironmentRouteLayer,
...@@ -268,12 +255,34 @@ export const makeServerLayer = Layer.unwrap(...@@ -268,12 +255,34 @@ export const makeServerLayer = Layer.unwrap(
268 yield* startup.markHttpListening;255 yield* startup.markHttpListening;
269 }),256 }),
270 );257 );
258 const runtimeStateLayer = Layer.effectDiscard(
259 Effect.acquireRelease(
260 Effect.gen(function* () {
261 const server = yield* HttpServer.HttpServer;
262 const address = server.address;
263 if (typeof address === "string" || !("port" in address)) {
264 return;
265 }
266
267 const state = makePersistedServerRuntimeState({
268 config,
269 port: address.port,
270 });
271 yield* persistServerRuntimeState({
272 path: config.serverRuntimeStatePath,
273 state,
274 });
275 }),
276 () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath),
277 ),
278 );
271279
272 const serverApplicationLayer = Layer.mergeAll(280 const serverApplicationLayer = Layer.mergeAll(
273 HttpRouter.serve(makeRoutesLayer, {281 HttpRouter.serve(makeRoutesLayer, {
274 disableLogger: !config.logWebSocketEvents,282 disableLogger: !config.logWebSocketEvents,
275 }),283 }),
276 httpListeningLayer,284 httpListeningLayer,
285 runtimeStateLayer,
277 );286 );
278287
279 return serverApplicationLayer.pipe(288 return serverApplicationLayer.pipe(
apps/server/src/serverRuntimeStartup.ts+22-12
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 Ref,18 Ref,
19 Scope,19 Scope,
20 ServiceMap,20 ServiceMap,
21 Console,
21} from "effect";22} from "effect";
2223
23import { ServerConfig } from "./config";24import { ServerConfig } from "./config";
...@@ -31,12 +32,12 @@ import { ServerSettingsService } from "./serverSettings";...@@ -31,12 +32,12 @@ import { ServerSettingsService } from "./serverSettings";
31import { ServerEnvironment } from "./environment/Services/ServerEnvironment";32import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
32import { AnalyticsService } from "./telemetry/Services/AnalyticsService";33import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
33import { ServerAuth } from "./auth/Services/ServerAuth";34import { ServerAuth } from "./auth/Services/ServerAuth";
3435import {
35const isWildcardHost = (host: string | undefined): boolean =>36 formatHeadlessServeOutput,
36 host === "0.0.0.0" || host === "::" || host === "[::]";37 formatHostForUrl,
3738 isWildcardHost,
38const formatHostForUrl = (host: string): string =>39 issueHeadlessServeAccessInfo,
39 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;40} from "./startupAccess";
4041
41export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{42export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
42 readonly message: string;43 readonly message: string;
...@@ -383,14 +384,23 @@ const makeServerRuntimeStartup = Effect.gen(function* () {...@@ -383,14 +384,23 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
383384
384 yield* Effect.logDebug("startup phase: recording startup heartbeat");385 yield* Effect.logDebug("startup phase: recording startup heartbeat");
385 yield* launchStartupHeartbeat;386 yield* launchStartupHeartbeat;
386 yield* Effect.logDebug("startup phase: browser open check");387 if (serverConfig.startupPresentation === "headless") {
387 const startupBrowserTarget = yield* resolveStartupBrowserTarget;388 yield* Effect.logDebug("startup phase: headless access info");
388 if (serverConfig.mode !== "desktop") {389 const accessInfo = yield* issueHeadlessServeAccessInfo();
389 yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe(390 yield* runStartupPhase(
390 Effect.annotateLogs({ pairingUrl: startupBrowserTarget }),391 "headless.output",
392 Console.log(formatHeadlessServeOutput(accessInfo)),
391 );393 );
394 } else {
395 yield* Effect.logDebug("startup phase: browser open check");
396 const startupBrowserTarget = yield* resolveStartupBrowserTarget;
397 if (serverConfig.mode !== "desktop") {
398 yield* Effect.logInfo(
399 "Authentication required. Open T3 Code using the pairing URL.",
400 ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget }));
401 }
402 yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget));
392 }403 }
393 yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget));
394 yield* Effect.logDebug("startup phase: complete");404 yield* Effect.logDebug("startup phase: complete");
395 }),405 }),
396 );406 );
apps/server/src/serverRuntimeState.ts created+77
...@@ -0,0 +1,77 @@
1import { Effect, FileSystem, Option, Path, Schema } from "effect";
2
3import { type ServerConfigShape } from "./config";
4import { formatHostForUrl, isWildcardHost } from "./startupAccess";
5
6export const PersistedServerRuntimeState = Schema.Struct({
7 version: Schema.Literal(1),
8 pid: Schema.Int,
9 host: Schema.optional(Schema.String),
10 port: Schema.Int,
11 origin: Schema.String,
12 startedAt: Schema.String,
13});
14export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;
15
16const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect(
17 Schema.fromJsonString(PersistedServerRuntimeState),
18);
19
20const runtimeOriginForConfig = (
21 config: Pick<ServerConfigShape, "host">,
22 port: number,
23): PersistedServerRuntimeState["origin"] => {
24 const hostname =
25 config.host && !isWildcardHost(config.host) ? formatHostForUrl(config.host) : "127.0.0.1";
26 return `http://${hostname}:${port}`;
27};
28
29export const makePersistedServerRuntimeState = (input: {
30 readonly config: Pick<ServerConfigShape, "host">;
31 readonly port: number;
32}): PersistedServerRuntimeState => ({
33 version: 1,
34 pid: process.pid,
35 ...(input.config.host ? { host: input.config.host } : {}),
36 port: input.port,
37 origin: runtimeOriginForConfig(input.config, input.port),
38 startedAt: new Date().toISOString(),
39});
40
41export const persistServerRuntimeState = (input: {
42 readonly path: string;
43 readonly state: PersistedServerRuntimeState;
44}) =>
45 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const pathService = yield* Path.Path;
48 const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`;
49 return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe(
50 Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)),
51 Effect.flatMap(() => fs.rename(tempPath, input.path)),
52 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
53 );
54 });
55
56export const clearPersistedServerRuntimeState = (path: string) =>
57 Effect.gen(function* () {
58 const fs = yield* FileSystem.FileSystem;
59 yield* fs.remove(path, { force: true }).pipe(Effect.ignore({ log: true }));
60 });
61
62export const readPersistedServerRuntimeState = (path: string) =>
63 Effect.gen(function* () {
64 const fs = yield* FileSystem.FileSystem;
65 const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false));
66 if (!exists) {
67 return Option.none<PersistedServerRuntimeState>();
68 }
69
70 const raw = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => ""));
71 const trimmed = raw.trim();
72 if (trimmed.length === 0) {
73 return Option.none<PersistedServerRuntimeState>();
74 }
75
76 return yield* decodePersistedServerRuntimeState(trimmed).pipe(Effect.option);
77 });
apps/server/src/startupAccess.test.ts created+79
...@@ -0,0 +1,79 @@
1import { assert, expect, it } from "@effect/vitest";
2
3import {
4 buildPairingUrl,
5 formatHeadlessServeOutput,
6 renderTerminalQrCode,
7 resolveHeadlessConnectionHost,
8 resolveHeadlessConnectionString,
9 resolveListeningPort,
10} from "./startupAccess";
11
12it("prefers localhost when no explicit host is configured", () => {
13 expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost");
14 expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773");
15});
16
17it("keeps explicit bind hosts in the connection string", () => {
18 expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773");
19 expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773");
20});
21
22it("resolves wildcard hosts to a concrete external interface when one is available", () => {
23 const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, {
24 en0: [
25 {
26 address: "192.168.1.42",
27 netmask: "255.255.255.0",
28 family: "IPv4",
29 mac: "00:00:00:00:00:00",
30 internal: false,
31 cidr: "192.168.1.42/24",
32 },
33 ],
34 lo0: [
35 {
36 address: "127.0.0.1",
37 netmask: "255.0.0.0",
38 family: "IPv4",
39 mac: "00:00:00:00:00:00",
40 internal: true,
41 cidr: "127.0.0.1/8",
42 },
43 ],
44 });
45
46 expect(connectionString).toBe("http://192.168.1.42:3773");
47});
48
49it("prefers the actual bound port when an http server address is available", () => {
50 expect(resolveListeningPort({ port: 4123 }, 3773)).toBe(4123);
51 expect(resolveListeningPort("pipe", 3773)).toBe(3773);
52 expect(resolveListeningPort(null, 3773)).toBe(3773);
53});
54
55it("builds a pairing URL that embeds the token in the hash", () => {
56 expect(buildPairingUrl("http://192.168.1.42:3773", "PAIRCODE")).toBe(
57 "http://192.168.1.42:3773/pair#token=PAIRCODE",
58 );
59});
60
61it("renders terminal QR codes as a multi-line unicode block grid", () => {
62 const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE");
63
64 assert.isTrue(qrCode.includes("█"));
65 assert.isTrue(qrCode.split("\n").length > 10);
66});
67
68it("formats headless serve output with the connection string, token, pairing url, and qr code", () => {
69 const output = formatHeadlessServeOutput({
70 connectionString: "http://192.168.1.42:3773",
71 token: "PAIRCODE",
72 pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE",
73 });
74
75 expect(output).toContain("Connection string: http://192.168.1.42:3773");
76 expect(output).toContain("Token: PAIRCODE");
77 expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE");
78 assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄"));
79});
apps/server/src/startupAccess.ts created+148
...@@ -0,0 +1,148 @@
1import { networkInterfaces } from "node:os";
2
3import { QrCode } from "@t3tools/shared/qrCode";
4import { Effect } from "effect";
5import { HttpServer } from "effect/unstable/http";
6
7import { ServerConfig } from "./config";
8import { ServerAuth } from "./auth/Services/ServerAuth";
9
10export interface HeadlessServeAccessInfo {
11 readonly connectionString: string;
12 readonly token: string;
13 readonly pairingUrl: string;
14}
15
16type NetworkInterfacesMap = ReturnType<typeof networkInterfaces>;
17
18export const isLoopbackHost = (host: string | undefined): boolean => {
19 if (!host || host.length === 0) {
20 return true;
21 }
22
23 return (
24 host === "localhost" ||
25 host === "127.0.0.1" ||
26 host === "::1" ||
27 host === "[::1]" ||
28 host.startsWith("127.")
29 );
30};
31
32export const isWildcardHost = (host: string | undefined): boolean =>
33 host === "0.0.0.0" || host === "::" || host === "[::]";
34
35export const formatHostForUrl = (host: string): string =>
36 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
37
38const normalizeHost = (host: string): string =>
39 host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
40
41const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4;
42
43const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6;
44
45export const resolveHeadlessConnectionHost = (
46 host: string | undefined,
47 interfaces: NetworkInterfacesMap = networkInterfaces(),
48): string => {
49 if (!host) {
50 return "localhost";
51 }
52
53 if (!isWildcardHost(host)) {
54 return normalizeHost(host);
55 }
56
57 const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []);
58 const externalIpv4 = interfaceEntries.find(
59 (entry) => !entry.internal && isIpv4Family(entry.family),
60 );
61 if (externalIpv4) {
62 return externalIpv4.address;
63 }
64
65 const externalIpv6 = interfaceEntries.find(
66 (entry) => !entry.internal && isIpv6Family(entry.family),
67 );
68 return externalIpv6 ? normalizeHost(externalIpv6.address) : "localhost";
69};
70
71export const resolveHeadlessConnectionString = (
72 host: string | undefined,
73 port: number,
74 interfaces: NetworkInterfacesMap = networkInterfaces(),
75): string => {
76 const connectionHost = resolveHeadlessConnectionHost(host, interfaces);
77 return `http://${formatHostForUrl(connectionHost)}:${port}`;
78};
79
80export const resolveListeningPort = (address: unknown, fallbackPort: number): number => {
81 if (
82 typeof address === "object" &&
83 address !== null &&
84 "port" in address &&
85 typeof address.port === "number"
86 ) {
87 return address.port;
88 }
89 return fallbackPort;
90};
91
92export const buildPairingUrl = (connectionString: string, token: string): string => {
93 const url = new URL(connectionString);
94 url.pathname = "/pair";
95 url.searchParams.delete("token");
96 url.hash = new URLSearchParams([["token", token]]).toString();
97 return url.toString();
98};
99
100export const renderTerminalQrCode = (value: string, margin = 2): string => {
101 const qrCode = QrCode.encodeText(value, QrCode.Ecc.MEDIUM);
102 const rows: Array<string> = [];
103 const isDark = (x: number, y: number): boolean =>
104 x >= 0 && x < qrCode.size && y >= 0 && y < qrCode.size && qrCode.getModule(x, y);
105
106 for (let y = -margin; y < qrCode.size + margin; y += 2) {
107 let row = "";
108
109 for (let x = -margin; x < qrCode.size + margin; x += 1) {
110 const topDark = isDark(x, y);
111 const bottomDark = isDark(x, y + 1);
112
113 row += topDark ? (bottomDark ? "█" : "▀") : bottomDark ? "▄" : " ";
114 }
115
116 rows.push(row);
117 }
118
119 return rows.join("\n");
120};
121
122export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string =>
123 [
124 "T3 Code server is ready.",
125 `Connection string: ${accessInfo.connectionString}`,
126 `Token: ${accessInfo.token}`,
127 `Pairing URL: ${accessInfo.pairingUrl}`,
128 "",
129 renderTerminalQrCode(accessInfo.pairingUrl),
130 "",
131 ].join("\n");
132
133export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () {
134 const serverConfig = yield* ServerConfig;
135 const httpServer = yield* HttpServer.HttpServer;
136 const serverAuth = yield* ServerAuth;
137 const connectionString = resolveHeadlessConnectionString(
138 serverConfig.host,
139 resolveListeningPort(httpServer.address, serverConfig.port),
140 );
141 const issued = yield* serverAuth.issuePairingCredential({ role: "owner" });
142
143 return {
144 connectionString,
145 token: issued.credential,
146 pairingUrl: buildPairingUrl(connectionString, issued.credential),
147 } satisfies HeadlessServeAccessInfo;
148});
apps/web/src/components/ui/qr-code.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { memo } from "react";1import { memo } from "react";
2import { QrCode } from "../../lib/vendor/qrcodegen";2import { QrCode } from "@t3tools/shared/qrCode";
33
4type QRCodeSvgProps = {4type QRCodeSvgProps = {
5 value: string;5 value: string;
apps/web/src/lib/vendor/qrcodegen.ts deleted-996
...@@ -1,996 +0,0 @@
1// @ts-nocheck
2/* oxlint-disable eslint/no-useless-escape */
3/*
4 * QR Code generator library (TypeScript)
5 *
6 * Copyright (c) Project Nayuki. (MIT License)
7 * https://www.nayuki.io/page/qr-code-generator-library
8 *
9 * Permission is hereby granted, free of charge, to any person obtaining a copy of
10 * this software and associated documentation files (the "Software"), to deal in
11 * the Software without restriction, including without limitation the rights to
12 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
13 * the Software, and to permit persons to whom the Software is furnished to do so,
14 * subject to the following conditions:
15 * - The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
17 * - The Software is provided "as is", without warranty of any kind, express or
18 * implied, including but not limited to the warranties of merchantability,
19 * fitness for a particular purpose and noninfringement. In no event shall the
20 * authors or copyright holders be liable for any claim, damages or other
21 * liability, whether in an action of contract, tort or otherwise, arising from,
22 * out of or in connection with the Software or the use or other dealings in the
23 * Software.
24 */
25
26"use strict";
27
28
29namespace qrcodegen {
30
31 type bit = number;
32 type byte = number;
33 type int = number;
34
35
36 /*---- QR Code symbol class ----*/
37
38 /*
39 * A QR Code symbol, which is a type of two-dimension barcode.
40 * Invented by Denso Wave and described in the ISO/IEC 18004 standard.
41 * Instances of this class represent an immutable square grid of dark and light cells.
42 * The class provides static factory functions to create a QR Code from text or binary data.
43 * The class covers the QR Code Model 2 specification, supporting all versions (sizes)
44 * from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
45 *
46 * Ways to create a QR Code object:
47 * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().
48 * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().
49 * - Low level: Custom-make the array of data codeword bytes (including
50 * segment headers and final padding, excluding error correction codewords),
51 * supply the appropriate version number, and call the QrCode() constructor.
52 * (Note that all ways require supplying the desired error correction level.)
53 */
54 export class QrCode {
55
56 /*-- Static factory functions (high level) --*/
57
58 // Returns a QR Code representing the given Unicode text string at the given error correction level.
59 // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
60 // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
61 // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
62 // ecl argument if it can be done without increasing the version.
63 public static encodeText(text: string, ecl: QrCode.Ecc): QrCode {
64 const segs: Array<QrSegment> = qrcodegen.QrSegment.makeSegments(text);
65 return QrCode.encodeSegments(segs, ecl);
66 }
67
68
69 // Returns a QR Code representing the given binary data at the given error correction level.
70 // This function always encodes using the binary segment mode, not any text mode. The maximum number of
71 // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
72 // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
73 public static encodeBinary(data: Readonly<Array<byte>>, ecl: QrCode.Ecc): QrCode {
74 const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data);
75 return QrCode.encodeSegments([seg], ecl);
76 }
77
78
79 /*-- Static factory functions (mid level) --*/
80
81 // Returns a QR Code representing the given segments with the given encoding parameters.
82 // The smallest possible QR Code version within the given range is automatically
83 // chosen for the output. Iff boostEcl is true, then the ECC level of the result
84 // may be higher than the ecl argument if it can be done without increasing the
85 // version. The mask number is either between 0 to 7 (inclusive) to force that
86 // mask, or -1 to automatically choose an appropriate mask (which may be slow).
87 // This function allows the user to create a custom sequence of segments that switches
88 // between modes (such as alphanumeric and byte) to encode text in less space.
89 // This is a mid-level API; the high-level API is encodeText() and encodeBinary().
90 public static encodeSegments(segs: Readonly<Array<QrSegment>>, ecl: QrCode.Ecc,
91 minVersion: int = 1, maxVersion: int = 40,
92 mask: int = -1, boostEcl: boolean = true): QrCode {
93
94 if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION)
95 || mask < -1 || mask > 7)
96 throw new RangeError("Invalid value");
97
98 // Find the minimal version number to use
99 let version: int;
100 let dataUsedBits: int;
101 for (version = minVersion; ; version++) {
102 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available
103 const usedBits: number = QrSegment.getTotalBits(segs, version);
104 if (usedBits <= dataCapacityBits) {
105 dataUsedBits = usedBits;
106 break; // This version number is found to be suitable
107 }
108 if (version >= maxVersion) // All versions in the range could not fit the given data
109 throw new RangeError("Data too long");
110 }
111
112 // Increase the error correction level while the data still fits in the current version number
113 for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high
114 if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8)
115 ecl = newEcl;
116 }
117
118 // Concatenate all segments to create the data bit string
119 let bb: Array<bit> = []
120 for (const seg of segs) {
121 appendBits(seg.mode.modeBits, 4, bb);
122 appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
123 for (const b of seg.getData())
124 bb.push(b);
125 }
126 assert(bb.length == dataUsedBits);
127
128 // Add terminator and pad up to a byte if applicable
129 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8;
130 assert(bb.length <= dataCapacityBits);
131 appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
132 appendBits(0, (8 - bb.length % 8) % 8, bb);
133 assert(bb.length % 8 == 0);
134
135 // Pad with alternating bytes until data capacity is reached
136 for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
137 appendBits(padByte, 8, bb);
138
139 // Pack bits into bytes in big endian
140 let dataCodewords: Array<byte> = [];
141 while (dataCodewords.length * 8 < bb.length)
142 dataCodewords.push(0);
143 bb.forEach((b: bit, i: int) =>
144 dataCodewords[i >>> 3] |= b << (7 - (i & 7)));
145
146 // Create the QR Code object
147 return new QrCode(version, ecl, dataCodewords, mask);
148 }
149
150
151 /*-- Fields --*/
152
153 // The width and height of this QR Code, measured in modules, between
154 // 21 and 177 (inclusive). This is equal to version * 4 + 17.
155 public readonly size: int;
156
157 // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
158 // Even if a QR Code is created with automatic masking requested (mask = -1),
159 // the resulting object still has a mask value between 0 and 7.
160 public readonly mask: int;
161
162 // The modules of this QR Code (false = light, true = dark).
163 // Immutable after constructor finishes. Accessed through getModule().
164 private readonly modules : Array<Array<boolean>> = [];
165
166 // Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
167 private readonly isFunction: Array<Array<boolean>> = [];
168
169
170 /*-- Constructor (low level) and fields --*/
171
172 // Creates a new QR Code with the given version number,
173 // error correction level, data codeword bytes, and mask number.
174 // This is a low-level API that most users should not use directly.
175 // A mid-level API is the encodeSegments() function.
176 public constructor(
177 // The version number of this QR Code, which is between 1 and 40 (inclusive).
178 // This determines the size of this barcode.
179 public readonly version: int,
180
181 // The error correction level used in this QR Code.
182 public readonly errorCorrectionLevel: QrCode.Ecc,
183
184 dataCodewords: Readonly<Array<byte>>,
185
186 msk: int) {
187
188 // Check scalar arguments
189 if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
190 throw new RangeError("Version value out of range");
191 if (msk < -1 || msk > 7)
192 throw new RangeError("Mask value out of range");
193 this.size = version * 4 + 17;
194
195 // Initialize both grids to be size*size arrays of Boolean false
196 let row: Array<boolean> = [];
197 for (let i = 0; i < this.size; i++)
198 row.push(false);
199 for (let i = 0; i < this.size; i++) {
200 this.modules .push(row.slice()); // Initially all light
201 this.isFunction.push(row.slice());
202 }
203
204 // Compute ECC, draw modules
205 this.drawFunctionPatterns();
206 const allCodewords: Array<byte> = this.addEccAndInterleave(dataCodewords);
207 this.drawCodewords(allCodewords);
208
209 // Do masking
210 if (msk == -1) { // Automatically choose best mask
211 let minPenalty: int = 1000000000;
212 for (let i = 0; i < 8; i++) {
213 this.applyMask(i);
214 this.drawFormatBits(i);
215 const penalty: int = this.getPenaltyScore();
216 if (penalty < minPenalty) {
217 msk = i;
218 minPenalty = penalty;
219 }
220 this.applyMask(i); // Undoes the mask due to XOR
221 }
222 }
223 assert(0 <= msk && msk <= 7);
224 this.mask = msk;
225 this.applyMask(msk); // Apply the final choice of mask
226 this.drawFormatBits(msk); // Overwrite old format bits
227
228 this.isFunction = [];
229 }
230
231
232 /*-- Accessor methods --*/
233
234 // Returns the color of the module (pixel) at the given coordinates, which is false
235 // for light or true for dark. The top left corner has the coordinates (x=0, y=0).
236 // If the given coordinates are out of bounds, then false (light) is returned.
237 public getModule(x: int, y: int): boolean {
238 return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x];
239 }
240
241
242 /*-- Private helper methods for constructor: Drawing function modules --*/
243
244 // Reads this object's version field, and draws and marks all function modules.
245 private drawFunctionPatterns(): void {
246 // Draw horizontal and vertical timing patterns
247 for (let i = 0; i < this.size; i++) {
248 this.setFunctionModule(6, i, i % 2 == 0);
249 this.setFunctionModule(i, 6, i % 2 == 0);
250 }
251
252 // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
253 this.drawFinderPattern(3, 3);
254 this.drawFinderPattern(this.size - 4, 3);
255 this.drawFinderPattern(3, this.size - 4);
256
257 // Draw numerous alignment patterns
258 const alignPatPos: Array<int> = this.getAlignmentPatternPositions();
259 const numAlign: int = alignPatPos.length;
260 for (let i = 0; i < numAlign; i++) {
261 for (let j = 0; j < numAlign; j++) {
262 // Don't draw on the three finder corners
263 if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0))
264 this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
265 }
266 }
267
268 // Draw configuration data
269 this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
270 this.drawVersion();
271 }
272
273
274 // Draws two copies of the format bits (with its own error correction code)
275 // based on the given mask and this object's error correction level field.
276 private drawFormatBits(mask: int): void {
277 // Calculate error correction code and pack bits
278 const data: int = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3
279 let rem: int = data;
280 for (let i = 0; i < 10; i++)
281 rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
282 const bits = (data << 10 | rem) ^ 0x5412; // uint15
283 assert(bits >>> 15 == 0);
284
285 // Draw first copy
286 for (let i = 0; i <= 5; i++)
287 this.setFunctionModule(8, i, getBit(bits, i));
288 this.setFunctionModule(8, 7, getBit(bits, 6));
289 this.setFunctionModule(8, 8, getBit(bits, 7));
290 this.setFunctionModule(7, 8, getBit(bits, 8));
291 for (let i = 9; i < 15; i++)
292 this.setFunctionModule(14 - i, 8, getBit(bits, i));
293
294 // Draw second copy
295 for (let i = 0; i < 8; i++)
296 this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
297 for (let i = 8; i < 15; i++)
298 this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
299 this.setFunctionModule(8, this.size - 8, true); // Always dark
300 }
301
302
303 // Draws two copies of the version bits (with its own error correction code),
304 // based on this object's version field, iff 7 <= version <= 40.
305 private drawVersion(): void {
306 if (this.version < 7)
307 return;
308
309 // Calculate error correction code and pack bits
310 let rem: int = this.version; // version is uint6, in the range [7, 40]
311 for (let i = 0; i < 12; i++)
312 rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
313 const bits: int = this.version << 12 | rem; // uint18
314 assert(bits >>> 18 == 0);
315
316 // Draw two copies
317 for (let i = 0; i < 18; i++) {
318 const color: boolean = getBit(bits, i);
319 const a: int = this.size - 11 + i % 3;
320 const b: int = Math.floor(i / 3);
321 this.setFunctionModule(a, b, color);
322 this.setFunctionModule(b, a, color);
323 }
324 }
325
326
327 // Draws a 9*9 finder pattern including the border separator,
328 // with the center module at (x, y). Modules can be out of bounds.
329 private drawFinderPattern(x: int, y: int): void {
330 for (let dy = -4; dy <= 4; dy++) {
331 for (let dx = -4; dx <= 4; dx++) {
332 const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm
333 const xx: int = x + dx;
334 const yy: int = y + dy;
335 if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
336 this.setFunctionModule(xx, yy, dist != 2 && dist != 4);
337 }
338 }
339 }
340
341
342 // Draws a 5*5 alignment pattern, with the center module
343 // at (x, y). All modules must be in bounds.
344 private drawAlignmentPattern(x: int, y: int): void {
345 for (let dy = -2; dy <= 2; dy++) {
346 for (let dx = -2; dx <= 2; dx++)
347 this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
348 }
349 }
350
351
352 // Sets the color of a module and marks it as a function module.
353 // Only used by the constructor. Coordinates must be in bounds.
354 private setFunctionModule(x: int, y: int, isDark: boolean): void {
355 this.modules[y][x] = isDark;
356 this.isFunction[y][x] = true;
357 }
358
359
360 /*-- Private helper methods for constructor: Codewords and masking --*/
361
362 // Returns a new byte string representing the given data with the appropriate error correction
363 // codewords appended to it, based on this object's version and error correction level.
364 private addEccAndInterleave(data: Readonly<Array<byte>>): Array<byte> {
365 const ver: int = this.version;
366 const ecl: QrCode.Ecc = this.errorCorrectionLevel;
367 if (data.length != QrCode.getNumDataCodewords(ver, ecl))
368 throw new RangeError("Invalid argument");
369
370 // Calculate parameter numbers
371 const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
372 const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver];
373 const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
374 const numShortBlocks: int = numBlocks - rawCodewords % numBlocks;
375 const shortBlockLen: int = Math.floor(rawCodewords / numBlocks);
376
377 // Split data into blocks and append ECC to each block
378 let blocks: Array<Array<byte>> = [];
379 const rsDiv: Array<byte> = QrCode.reedSolomonComputeDivisor(blockEccLen);
380 for (let i = 0, k = 0; i < numBlocks; i++) {
381 let dat: Array<byte> = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
382 k += dat.length;
383 const ecc: Array<byte> = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
384 if (i < numShortBlocks)
385 dat.push(0);
386 blocks.push(dat.concat(ecc));
387 }
388
389 // Interleave (not concatenate) the bytes from every block into a single sequence
390 let result: Array<byte> = [];
391 for (let i = 0; i < blocks[0].length; i++) {
392 blocks.forEach((block, j) => {
393 // Skip the padding byte in short blocks
394 if (i != shortBlockLen - blockEccLen || j >= numShortBlocks)
395 result.push(block[i]);
396 });
397 }
398 assert(result.length == rawCodewords);
399 return result;
400 }
401
402
403 // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
404 // data area of this QR Code. Function modules need to be marked off before this is called.
405 private drawCodewords(data: Readonly<Array<byte>>): void {
406 if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
407 throw new RangeError("Invalid argument");
408 let i: int = 0; // Bit index into the data
409 // Do the funny zigzag scan
410 for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair
411 if (right == 6)
412 right = 5;
413 for (let vert = 0; vert < this.size; vert++) { // Vertical counter
414 for (let j = 0; j < 2; j++) {
415 const x: int = right - j; // Actual x coordinate
416 const upward: boolean = ((right + 1) & 2) == 0;
417 const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate
418 if (!this.isFunction[y][x] && i < data.length * 8) {
419 this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
420 i++;
421 }
422 // If this QR Code has any remainder bits (0 to 7), they were assigned as
423 // 0/false/light by the constructor and are left unchanged by this method
424 }
425 }
426 }
427 assert(i == data.length * 8);
428 }
429
430
431 // XORs the codeword modules in this QR Code with the given mask pattern.
432 // The function modules must be marked and the codeword bits must be drawn
433 // before masking. Due to the arithmetic of XOR, calling applyMask() with
434 // the same mask value a second time will undo the mask. A final well-formed
435 // QR Code needs exactly one (not zero, two, etc.) mask applied.
436 private applyMask(mask: int): void {
437 if (mask < 0 || mask > 7)
438 throw new RangeError("Mask value out of range");
439 for (let y = 0; y < this.size; y++) {
440 for (let x = 0; x < this.size; x++) {
441 let invert: boolean;
442 switch (mask) {
443 case 0: invert = (x + y) % 2 == 0; break;
444 case 1: invert = y % 2 == 0; break;
445 case 2: invert = x % 3 == 0; break;
446 case 3: invert = (x + y) % 3 == 0; break;
447 case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; break;
448 case 5: invert = x * y % 2 + x * y % 3 == 0; break;
449 case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break;
450 case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break;
451 default: throw new Error("Unreachable");
452 }
453 if (!this.isFunction[y][x] && invert)
454 this.modules[y][x] = !this.modules[y][x];
455 }
456 }
457 }
458
459
460 // Calculates and returns the penalty score based on state of this QR Code's current modules.
461 // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
462 private getPenaltyScore(): int {
463 let result: int = 0;
464
465 // Adjacent modules in row having same color, and finder-like patterns
466 for (let y = 0; y < this.size; y++) {
467 let runColor = false;
468 let runX = 0;
469 let runHistory = [0,0,0,0,0,0,0];
470 for (let x = 0; x < this.size; x++) {
471 if (this.modules[y][x] == runColor) {
472 runX++;
473 if (runX == 5)
474 result += QrCode.PENALTY_N1;
475 else if (runX > 5)
476 result++;
477 } else {
478 this.finderPenaltyAddHistory(runX, runHistory);
479 if (!runColor)
480 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
481 runColor = this.modules[y][x];
482 runX = 1;
483 }
484 }
485 result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
486 }
487 // Adjacent modules in column having same color, and finder-like patterns
488 for (let x = 0; x < this.size; x++) {
489 let runColor = false;
490 let runY = 0;
491 let runHistory = [0,0,0,0,0,0,0];
492 for (let y = 0; y < this.size; y++) {
493 if (this.modules[y][x] == runColor) {
494 runY++;
495 if (runY == 5)
496 result += QrCode.PENALTY_N1;
497 else if (runY > 5)
498 result++;
499 } else {
500 this.finderPenaltyAddHistory(runY, runHistory);
501 if (!runColor)
502 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
503 runColor = this.modules[y][x];
504 runY = 1;
505 }
506 }
507 result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
508 }
509
510 // 2*2 blocks of modules having same color
511 for (let y = 0; y < this.size - 1; y++) {
512 for (let x = 0; x < this.size - 1; x++) {
513 const color: boolean = this.modules[y][x];
514 if ( color == this.modules[y][x + 1] &&
515 color == this.modules[y + 1][x] &&
516 color == this.modules[y + 1][x + 1])
517 result += QrCode.PENALTY_N2;
518 }
519 }
520
521 // Balance of dark and light modules
522 let dark: int = 0;
523 for (const row of this.modules)
524 dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);
525 const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2
526 // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
527 const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
528 assert(0 <= k && k <= 9);
529 result += k * QrCode.PENALTY_N4;
530 assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
531 return result;
532 }
533
534
535 /*-- Private helper functions --*/
536
537 // Returns an ascending list of positions of alignment patterns for this version number.
538 // Each position is in the range [0,177), and are used on both the x and y axes.
539 // This could be implemented as lookup table of 40 variable-length lists of integers.
540 private getAlignmentPatternPositions(): Array<int> {
541 if (this.version == 1)
542 return [];
543 else {
544 const numAlign: int = Math.floor(this.version / 7) + 2;
545 const step: int = (this.version == 32) ? 26 :
546 Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
547 let result: Array<int> = [6];
548 for (let pos = this.size - 7; result.length < numAlign; pos -= step)
549 result.splice(1, 0, pos);
550 return result;
551 }
552 }
553
554
555 // Returns the number of data bits that can be stored in a QR Code of the given version number, after
556 // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
557 // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
558 private static getNumRawDataModules(ver: int): int {
559 if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)
560 throw new RangeError("Version number out of range");
561 let result: int = (16 * ver + 128) * ver + 64;
562 if (ver >= 2) {
563 const numAlign: int = Math.floor(ver / 7) + 2;
564 result -= (25 * numAlign - 10) * numAlign - 55;
565 if (ver >= 7)
566 result -= 36;
567 }
568 assert(208 <= result && result <= 29648);
569 return result;
570 }
571
572
573 // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
574 // QR Code of the given version number and error correction level, with remainder bits discarded.
575 // This stateless pure function could be implemented as a (40*4)-cell lookup table.
576 private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int {
577 return Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
578 QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] *
579 QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
580 }
581
582
583 // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
584 // implemented as a lookup table over all possible parameter values, instead of as an algorithm.
585 private static reedSolomonComputeDivisor(degree: int): Array<byte> {
586 if (degree < 1 || degree > 255)
587 throw new RangeError("Degree out of range");
588 // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
589 // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
590 let result: Array<byte> = [];
591 for (let i = 0; i < degree - 1; i++)
592 result.push(0);
593 result.push(1); // Start off with the monomial x^0
594
595 // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
596 // and drop the highest monomial term which is always 1x^degree.
597 // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
598 let root = 1;
599 for (let i = 0; i < degree; i++) {
600 // Multiply the current product by (x - r^i)
601 for (let j = 0; j < result.length; j++) {
602 result[j] = QrCode.reedSolomonMultiply(result[j], root);
603 if (j + 1 < result.length)
604 result[j] ^= result[j + 1];
605 }
606 root = QrCode.reedSolomonMultiply(root, 0x02);
607 }
608 return result;
609 }
610
611
612 // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
613 private static reedSolomonComputeRemainder(data: Readonly<Array<byte>>, divisor: Readonly<Array<byte>>): Array<byte> {
614 let result: Array<byte> = divisor.map(_ => 0);
615 for (const b of data) { // Polynomial division
616 const factor: byte = b ^ (result.shift() as byte);
617 result.push(0);
618 divisor.forEach((coef, i) =>
619 result[i] ^= QrCode.reedSolomonMultiply(coef, factor));
620 }
621 return result;
622 }
623
624
625 // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
626 // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.
627 private static reedSolomonMultiply(x: byte, y: byte): byte {
628 if (x >>> 8 != 0 || y >>> 8 != 0)
629 throw new RangeError("Byte out of range");
630 // Russian peasant multiplication
631 let z: int = 0;
632 for (let i = 7; i >= 0; i--) {
633 z = (z << 1) ^ ((z >>> 7) * 0x11D);
634 z ^= ((y >>> i) & 1) * x;
635 }
636 assert(z >>> 8 == 0);
637 return z as byte;
638 }
639
640
641 // Can only be called immediately after a light run is added, and
642 // returns either 0, 1, or 2. A helper function for getPenaltyScore().
643 private finderPenaltyCountPatterns(runHistory: Readonly<Array<int>>): int {
644 const n: int = runHistory[1];
645 assert(n <= this.size * 3);
646 const core: boolean = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n;
647 return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0)
648 + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0);
649 }
650
651
652 // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
653 private finderPenaltyTerminateAndCount(currentRunColor: boolean, currentRunLength: int, runHistory: Array<int>): int {
654 if (currentRunColor) { // Terminate dark run
655 this.finderPenaltyAddHistory(currentRunLength, runHistory);
656 currentRunLength = 0;
657 }
658 currentRunLength += this.size; // Add light border to final run
659 this.finderPenaltyAddHistory(currentRunLength, runHistory);
660 return this.finderPenaltyCountPatterns(runHistory);
661 }
662
663
664 // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
665 private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array<int>): void {
666 if (runHistory[0] == 0)
667 currentRunLength += this.size; // Add light border to initial run
668 runHistory.pop();
669 runHistory.unshift(currentRunLength);
670 }
671
672
673 /*-- Constants and tables --*/
674
675 // The minimum version number supported in the QR Code Model 2 standard.
676 public static readonly MIN_VERSION: int = 1;
677 // The maximum version number supported in the QR Code Model 2 standard.
678 public static readonly MAX_VERSION: int = 40;
679
680 // For use in getPenaltyScore(), when evaluating which mask is best.
681 private static readonly PENALTY_N1: int = 3;
682 private static readonly PENALTY_N2: int = 3;
683 private static readonly PENALTY_N3: int = 40;
684 private static readonly PENALTY_N4: int = 10;
685
686 private static readonly ECC_CODEWORDS_PER_BLOCK: Array<Array<int>> = [
687 // Version: (note that index 0 is for padding, and is set to an illegal value)
688 //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
689 [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Low
690 [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // Medium
691 [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Quartile
692 [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High
693 ];
694
695 private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array<Array<int>> = [
696 // Version: (note that index 0 is for padding, and is set to an illegal value)
697 //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
698 [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // Low
699 [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // Medium
700 [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Quartile
701 [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High
702 ];
703
704 }
705
706
707 // Appends the given number of low-order bits of the given value
708 // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.
709 function appendBits(val: int, len: int, bb: Array<bit>): void {
710 if (len < 0 || len > 31 || val >>> len != 0)
711 throw new RangeError("Value out of range");
712 for (let i = len - 1; i >= 0; i--) // Append bit by bit
713 bb.push((val >>> i) & 1);
714 }
715
716
717 // Returns true iff the i'th bit of x is set to 1.
718 function getBit(x: int, i: int): boolean {
719 return ((x >>> i) & 1) != 0;
720 }
721
722
723 // Throws an exception if the given condition is false.
724 function assert(cond: boolean): void {
725 if (!cond)
726 throw new Error("Assertion error");
727 }
728
729
730
731 /*---- Data segment class ----*/
732
733 /*
734 * A segment of character/binary/control data in a QR Code symbol.
735 * Instances of this class are immutable.
736 * The mid-level way to create a segment is to take the payload data
737 * and call a static factory function such as QrSegment.makeNumeric().
738 * The low-level way to create a segment is to custom-make the bit buffer
739 * and call the QrSegment() constructor with appropriate values.
740 * This segment class imposes no length restrictions, but QR Codes have restrictions.
741 * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
742 * Any segment longer than this is meaningless for the purpose of generating QR Codes.
743 */
744 export class QrSegment {
745
746 /*-- Static factory functions (mid level) --*/
747
748 // Returns a segment representing the given binary data encoded in
749 // byte mode. All input byte arrays are acceptable. Any text string
750 // can be converted to UTF-8 bytes and encoded as a byte mode segment.
751 public static makeBytes(data: Readonly<Array<byte>>): QrSegment {
752 let bb: Array<bit> = []
753 for (const b of data)
754 appendBits(b, 8, bb);
755 return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
756 }
757
758
759 // Returns a segment representing the given string of decimal digits encoded in numeric mode.
760 public static makeNumeric(digits: string): QrSegment {
761 if (!QrSegment.isNumeric(digits))
762 throw new RangeError("String contains non-numeric characters");
763 let bb: Array<bit> = []
764 for (let i = 0; i < digits.length; ) { // Consume up to 3 digits per iteration
765 const n: int = Math.min(digits.length - i, 3);
766 appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb);
767 i += n;
768 }
769 return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);
770 }
771
772
773 // Returns a segment representing the given text string encoded in alphanumeric mode.
774 // The characters allowed are: 0 to 9, A to Z (uppercase only), space,
775 // dollar, percent, asterisk, plus, hyphen, period, slash, colon.
776 public static makeAlphanumeric(text: string): QrSegment {
777 if (!QrSegment.isAlphanumeric(text))
778 throw new RangeError("String contains unencodable characters in alphanumeric mode");
779 let bb: Array<bit> = []
780 let i: int;
781 for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2
782 let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;
783 temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));
784 appendBits(temp, 11, bb);
785 }
786 if (i < text.length) // 1 character remaining
787 appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb);
788 return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);
789 }
790
791
792 // Returns a new mutable list of zero or more segments to represent the given Unicode text string.
793 // The result may use various segment modes and switch modes to optimize the length of the bit stream.
794 public static makeSegments(text: string): Array<QrSegment> {
795 // Select the most efficient segment encoding automatically
796 if (text == "")
797 return [];
798 else if (QrSegment.isNumeric(text))
799 return [QrSegment.makeNumeric(text)];
800 else if (QrSegment.isAlphanumeric(text))
801 return [QrSegment.makeAlphanumeric(text)];
802 else
803 return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];
804 }
805
806
807 // Returns a segment representing an Extended Channel Interpretation
808 // (ECI) designator with the given assignment value.
809 public static makeEci(assignVal: int): QrSegment {
810 let bb: Array<bit> = []
811 if (assignVal < 0)
812 throw new RangeError("ECI assignment value out of range");
813 else if (assignVal < (1 << 7))
814 appendBits(assignVal, 8, bb);
815 else if (assignVal < (1 << 14)) {
816 appendBits(0b10, 2, bb);
817 appendBits(assignVal, 14, bb);
818 } else if (assignVal < 1000000) {
819 appendBits(0b110, 3, bb);
820 appendBits(assignVal, 21, bb);
821 } else
822 throw new RangeError("ECI assignment value out of range");
823 return new QrSegment(QrSegment.Mode.ECI, 0, bb);
824 }
825
826
827 // Tests whether the given string can be encoded as a segment in numeric mode.
828 // A string is encodable iff each character is in the range 0 to 9.
829 public static isNumeric(text: string): boolean {
830 return QrSegment.NUMERIC_REGEX.test(text);
831 }
832
833
834 // Tests whether the given string can be encoded as a segment in alphanumeric mode.
835 // A string is encodable iff each character is in the following set: 0 to 9, A to Z
836 // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
837 public static isAlphanumeric(text: string): boolean {
838 return QrSegment.ALPHANUMERIC_REGEX.test(text);
839 }
840
841
842 /*-- Constructor (low level) and fields --*/
843
844 // Creates a new QR Code segment with the given attributes and data.
845 // The character count (numChars) must agree with the mode and the bit buffer length,
846 // but the constraint isn't checked. The given bit buffer is cloned and stored.
847 public constructor(
848 // The mode indicator of this segment.
849 public readonly mode: QrSegment.Mode,
850
851 // The length of this segment's unencoded data. Measured in characters for
852 // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
853 // Always zero or positive. Not the same as the data's bit length.
854 public readonly numChars: int,
855
856 // The data bits of this segment. Accessed through getData().
857 private readonly bitData: Array<bit>) {
858
859 if (numChars < 0)
860 throw new RangeError("Invalid argument");
861 this.bitData = bitData.slice(); // Make defensive copy
862 }
863
864
865 /*-- Methods --*/
866
867 // Returns a new copy of the data bits of this segment.
868 public getData(): Array<bit> {
869 return this.bitData.slice(); // Make defensive copy
870 }
871
872
873 // (Package-private) Calculates and returns the number of bits needed to encode the given segments at
874 // the given version. The result is infinity if a segment has too many characters to fit its length field.
875 public static getTotalBits(segs: Readonly<Array<QrSegment>>, version: int): number {
876 let result: number = 0;
877 for (const seg of segs) {
878 const ccbits: int = seg.mode.numCharCountBits(version);
879 if (seg.numChars >= (1 << ccbits))
880 return Infinity; // The segment's length doesn't fit the field's bit width
881 result += 4 + ccbits + seg.bitData.length;
882 }
883 return result;
884 }
885
886
887 // Returns a new array of bytes representing the given string encoded in UTF-8.
888 private static toUtf8ByteArray(str: string): Array<byte> {
889 str = encodeURI(str);
890 let result: Array<byte> = [];
891 for (let i = 0; i < str.length; i++) {
892 if (str.charAt(i) != "%")
893 result.push(str.charCodeAt(i));
894 else {
895 result.push(parseInt(str.substring(i + 1, i + 3), 16));
896 i += 2;
897 }
898 }
899 return result;
900 }
901
902
903 /*-- Constants --*/
904
905 // Describes precisely all strings that are encodable in numeric mode.
906 private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/;
907
908 // Describes precisely all strings that are encodable in alphanumeric mode.
909 private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/;
910
911 // The set of all legal characters in alphanumeric mode,
912 // where each character value maps to the index in the string.
913 private static readonly ALPHANUMERIC_CHARSET: string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
914
915 }
916
917}
918
919
920
921/*---- Public helper enumeration ----*/
922
923namespace qrcodegen.QrCode {
924
925 type int = number;
926
927
928 /*
929 * The error correction level in a QR Code symbol. Immutable.
930 */
931 export class Ecc {
932
933 /*-- Constants --*/
934
935 public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords
936 public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords
937 public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords
938 public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords
939
940
941 /*-- Constructor and fields --*/
942
943 private constructor(
944 // In the range 0 to 3 (unsigned 2-bit integer).
945 public readonly ordinal: int,
946 // (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
947 public readonly formatBits: int) {}
948
949 }
950}
951
952
953
954/*---- Public helper enumeration ----*/
955
956namespace qrcodegen.QrSegment {
957
958 type int = number;
959
960
961 /*
962 * Describes how a segment's data bits are interpreted. Immutable.
963 */
964 export class Mode {
965
966 /*-- Constants --*/
967
968 public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]);
969 public static readonly ALPHANUMERIC = new Mode(0x2, [ 9, 11, 13]);
970 public static readonly BYTE = new Mode(0x4, [ 8, 16, 16]);
971 public static readonly KANJI = new Mode(0x8, [ 8, 10, 12]);
972 public static readonly ECI = new Mode(0x7, [ 0, 0, 0]);
973
974
975 /*-- Constructor and fields --*/
976
977 private constructor(
978 // The mode indicator bits, which is a uint4 value (range 0 to 15).
979 public readonly modeBits: int,
980 // Number of character count bits for three different version ranges.
981 private readonly numBitsCharCount: [int,int,int]) {}
982
983
984 /*-- Method --*/
985
986 // (Package-private) Returns the bit width of the character count field for a segment in
987 // this mode in a QR Code at the given version number. The result is in the range [0, 16].
988 public numCharCountBits(ver: int): int {
989 return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
990 }
991
992 }
993}
994
995export const QrCode = qrcodegen.QrCode;
996export const QrSegment = qrcodegen.QrSegment;
packages/shared/package.json+4
...@@ -51,6 +51,10 @@...@@ -51,6 +51,10 @@
51 "./projectScripts": {51 "./projectScripts": {
52 "types": "./src/projectScripts.ts",52 "types": "./src/projectScripts.ts",
53 "import": "./src/projectScripts.ts"53 "import": "./src/projectScripts.ts"
54 },
55 "./qrCode": {
56 "types": "./src/qrCode.ts",
57 "import": "./src/qrCode.ts"
54 }58 }
55 },59 },
56 "scripts": {60 "scripts": {
packages/shared/src/qrCode.ts created+984
...@@ -0,0 +1,984 @@
1// @ts-nocheck
2/* oxlint-disable eslint/no-useless-escape */
3/*
4 * QR Code generator library (TypeScript)
5 *
6 * Copyright (c) Project Nayuki. (MIT License)
7 * https://www.nayuki.io/page/qr-code-generator-library
8 *
9 * Permission is hereby granted, free of charge, to any person obtaining a copy of
10 * this software and associated documentation files (the "Software"), to deal in
11 * the Software without restriction, including without limitation the rights to
12 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
13 * the Software, and to permit persons to whom the Software is furnished to do so,
14 * subject to the following conditions:
15 * - The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
17 * - The Software is provided "as is", without warranty of any kind, express or
18 * implied, including but not limited to the warranties of merchantability,
19 * fitness for a particular purpose and noninfringement. In no event shall the
20 * authors or copyright holders be liable for any claim, damages or other
21 * liability, whether in an action of contract, tort or otherwise, arising from,
22 * out of or in connection with the Software or the use or other dealings in the
23 * Software.
24 */
25
26"use strict";
27
28namespace qrcodegen {
29 type bit = number;
30 type byte = number;
31 type int = number;
32
33 /*---- QR Code symbol class ----*/
34
35 /*
36 * A QR Code symbol, which is a type of two-dimension barcode.
37 * Invented by Denso Wave and described in the ISO/IEC 18004 standard.
38 * Instances of this class represent an immutable square grid of dark and light cells.
39 * The class provides static factory functions to create a QR Code from text or binary data.
40 * The class covers the QR Code Model 2 specification, supporting all versions (sizes)
41 * from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
42 *
43 * Ways to create a QR Code object:
44 * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().
45 * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().
46 * - Low level: Custom-make the array of data codeword bytes (including
47 * segment headers and final padding, excluding error correction codewords),
48 * supply the appropriate version number, and call the QrCode() constructor.
49 * (Note that all ways require supplying the desired error correction level.)
50 */
51 export class QrCode {
52 /*-- Static factory functions (high level) --*/
53
54 // Returns a QR Code representing the given Unicode text string at the given error correction level.
55 // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
56 // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
57 // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
58 // ecl argument if it can be done without increasing the version.
59 public static encodeText(text: string, ecl: QrCode.Ecc): QrCode {
60 const segs: Array<QrSegment> = qrcodegen.QrSegment.makeSegments(text);
61 return QrCode.encodeSegments(segs, ecl);
62 }
63
64 // Returns a QR Code representing the given binary data at the given error correction level.
65 // This function always encodes using the binary segment mode, not any text mode. The maximum number of
66 // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
67 // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
68 public static encodeBinary(data: Readonly<Array<byte>>, ecl: QrCode.Ecc): QrCode {
69 const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data);
70 return QrCode.encodeSegments([seg], ecl);
71 }
72
73 /*-- Static factory functions (mid level) --*/
74
75 // Returns a QR Code representing the given segments with the given encoding parameters.
76 // The smallest possible QR Code version within the given range is automatically
77 // chosen for the output. Iff boostEcl is true, then the ECC level of the result
78 // may be higher than the ecl argument if it can be done without increasing the
79 // version. The mask number is either between 0 to 7 (inclusive) to force that
80 // mask, or -1 to automatically choose an appropriate mask (which may be slow).
81 // This function allows the user to create a custom sequence of segments that switches
82 // between modes (such as alphanumeric and byte) to encode text in less space.
83 // This is a mid-level API; the high-level API is encodeText() and encodeBinary().
84 public static encodeSegments(
85 segs: Readonly<Array<QrSegment>>,
86 ecl: QrCode.Ecc,
87 minVersion: int = 1,
88 maxVersion: int = 40,
89 mask: int = -1,
90 boostEcl: boolean = true,
91 ): QrCode {
92 if (
93 !(
94 QrCode.MIN_VERSION <= minVersion &&
95 minVersion <= maxVersion &&
96 maxVersion <= QrCode.MAX_VERSION
97 ) ||
98 mask < -1 ||
99 mask > 7
100 )
101 throw new RangeError("Invalid value");
102
103 // Find the minimal version number to use
104 let version: int;
105 let dataUsedBits: int;
106 for (version = minVersion; ; version++) {
107 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available
108 const usedBits: number = QrSegment.getTotalBits(segs, version);
109 if (usedBits <= dataCapacityBits) {
110 dataUsedBits = usedBits;
111 break; // This version number is found to be suitable
112 }
113 if (version >= maxVersion)
114 // All versions in the range could not fit the given data
115 throw new RangeError("Data too long");
116 }
117
118 // Increase the error correction level while the data still fits in the current version number
119 for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) {
120 // From low to high
121 if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8)
122 ecl = newEcl;
123 }
124
125 // Concatenate all segments to create the data bit string
126 let bb: Array<bit> = [];
127 for (const seg of segs) {
128 appendBits(seg.mode.modeBits, 4, bb);
129 appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
130 for (const b of seg.getData()) bb.push(b);
131 }
132 assert(bb.length == dataUsedBits);
133
134 // Add terminator and pad up to a byte if applicable
135 const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8;
136 assert(bb.length <= dataCapacityBits);
137 appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
138 appendBits(0, (8 - (bb.length % 8)) % 8, bb);
139 assert(bb.length % 8 == 0);
140
141 // Pad with alternating bytes until data capacity is reached
142 for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11)
143 appendBits(padByte, 8, bb);
144
145 // Pack bits into bytes in big endian
146 let dataCodewords: Array<byte> = [];
147 while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0);
148 bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7))));
149
150 // Create the QR Code object
151 return new QrCode(version, ecl, dataCodewords, mask);
152 }
153
154 /*-- Fields --*/
155
156 // The width and height of this QR Code, measured in modules, between
157 // 21 and 177 (inclusive). This is equal to version * 4 + 17.
158 public readonly size: int;
159
160 // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
161 // Even if a QR Code is created with automatic masking requested (mask = -1),
162 // the resulting object still has a mask value between 0 and 7.
163 public readonly mask: int;
164
165 // The modules of this QR Code (false = light, true = dark).
166 // Immutable after constructor finishes. Accessed through getModule().
167 private readonly modules: Array<Array<boolean>> = [];
168
169 // Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
170 private readonly isFunction: Array<Array<boolean>> = [];
171
172 /*-- Constructor (low level) and fields --*/
173
174 // Creates a new QR Code with the given version number,
175 // error correction level, data codeword bytes, and mask number.
176 // This is a low-level API that most users should not use directly.
177 // A mid-level API is the encodeSegments() function.
178 public constructor(
179 // The version number of this QR Code, which is between 1 and 40 (inclusive).
180 // This determines the size of this barcode.
181 public readonly version: int,
182
183 // The error correction level used in this QR Code.
184 public readonly errorCorrectionLevel: QrCode.Ecc,
185
186 dataCodewords: Readonly<Array<byte>>,
187
188 msk: int,
189 ) {
190 // Check scalar arguments
191 if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
192 throw new RangeError("Version value out of range");
193 if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range");
194 this.size = version * 4 + 17;
195
196 // Initialize both grids to be size*size arrays of Boolean false
197 let row: Array<boolean> = [];
198 for (let i = 0; i < this.size; i++) row.push(false);
199 for (let i = 0; i < this.size; i++) {
200 this.modules.push(row.slice()); // Initially all light
201 this.isFunction.push(row.slice());
202 }
203
204 // Compute ECC, draw modules
205 this.drawFunctionPatterns();
206 const allCodewords: Array<byte> = this.addEccAndInterleave(dataCodewords);
207 this.drawCodewords(allCodewords);
208
209 // Do masking
210 if (msk == -1) {
211 // Automatically choose best mask
212 let minPenalty: int = 1000000000;
213 for (let i = 0; i < 8; i++) {
214 this.applyMask(i);
215 this.drawFormatBits(i);
216 const penalty: int = this.getPenaltyScore();
217 if (penalty < minPenalty) {
218 msk = i;
219 minPenalty = penalty;
220 }
221 this.applyMask(i); // Undoes the mask due to XOR
222 }
223 }
224 assert(0 <= msk && msk <= 7);
225 this.mask = msk;
226 this.applyMask(msk); // Apply the final choice of mask
227 this.drawFormatBits(msk); // Overwrite old format bits
228
229 this.isFunction = [];
230 }
231
232 /*-- Accessor methods --*/
233
234 // Returns the color of the module (pixel) at the given coordinates, which is false
235 // for light or true for dark. The top left corner has the coordinates (x=0, y=0).
236 // If the given coordinates are out of bounds, then false (light) is returned.
237 public getModule(x: int, y: int): boolean {
238 return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x];
239 }
240
241 /*-- Private helper methods for constructor: Drawing function modules --*/
242
243 // Reads this object's version field, and draws and marks all function modules.
244 private drawFunctionPatterns(): void {
245 // Draw horizontal and vertical timing patterns
246 for (let i = 0; i < this.size; i++) {
247 this.setFunctionModule(6, i, i % 2 == 0);
248 this.setFunctionModule(i, 6, i % 2 == 0);
249 }
250
251 // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
252 this.drawFinderPattern(3, 3);
253 this.drawFinderPattern(this.size - 4, 3);
254 this.drawFinderPattern(3, this.size - 4);
255
256 // Draw numerous alignment patterns
257 const alignPatPos: Array<int> = this.getAlignmentPatternPositions();
258 const numAlign: int = alignPatPos.length;
259 for (let i = 0; i < numAlign; i++) {
260 for (let j = 0; j < numAlign; j++) {
261 // Don't draw on the three finder corners
262 if (
263 !((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))
264 )
265 this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
266 }
267 }
268
269 // Draw configuration data
270 this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
271 this.drawVersion();
272 }
273
274 // Draws two copies of the format bits (with its own error correction code)
275 // based on the given mask and this object's error correction level field.
276 private drawFormatBits(mask: int): void {
277 // Calculate error correction code and pack bits
278 const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3
279 let rem: int = data;
280 for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
281 const bits = ((data << 10) | rem) ^ 0x5412; // uint15
282 assert(bits >>> 15 == 0);
283
284 // Draw first copy
285 for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i));
286 this.setFunctionModule(8, 7, getBit(bits, 6));
287 this.setFunctionModule(8, 8, getBit(bits, 7));
288 this.setFunctionModule(7, 8, getBit(bits, 8));
289 for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i));
290
291 // Draw second copy
292 for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
293 for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
294 this.setFunctionModule(8, this.size - 8, true); // Always dark
295 }
296
297 // Draws two copies of the version bits (with its own error correction code),
298 // based on this object's version field, iff 7 <= version <= 40.
299 private drawVersion(): void {
300 if (this.version < 7) return;
301
302 // Calculate error correction code and pack bits
303 let rem: int = this.version; // version is uint6, in the range [7, 40]
304 for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
305 const bits: int = (this.version << 12) | rem; // uint18
306 assert(bits >>> 18 == 0);
307
308 // Draw two copies
309 for (let i = 0; i < 18; i++) {
310 const color: boolean = getBit(bits, i);
311 const a: int = this.size - 11 + (i % 3);
312 const b: int = Math.floor(i / 3);
313 this.setFunctionModule(a, b, color);
314 this.setFunctionModule(b, a, color);
315 }
316 }
317
318 // Draws a 9*9 finder pattern including the border separator,
319 // with the center module at (x, y). Modules can be out of bounds.
320 private drawFinderPattern(x: int, y: int): void {
321 for (let dy = -4; dy <= 4; dy++) {
322 for (let dx = -4; dx <= 4; dx++) {
323 const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm
324 const xx: int = x + dx;
325 const yy: int = y + dy;
326 if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
327 this.setFunctionModule(xx, yy, dist != 2 && dist != 4);
328 }
329 }
330 }
331
332 // Draws a 5*5 alignment pattern, with the center module
333 // at (x, y). All modules must be in bounds.
334 private drawAlignmentPattern(x: int, y: int): void {
335 for (let dy = -2; dy <= 2; dy++) {
336 for (let dx = -2; dx <= 2; dx++)
337 this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
338 }
339 }
340
341 // Sets the color of a module and marks it as a function module.
342 // Only used by the constructor. Coordinates must be in bounds.
343 private setFunctionModule(x: int, y: int, isDark: boolean): void {
344 this.modules[y][x] = isDark;
345 this.isFunction[y][x] = true;
346 }
347
348 /*-- Private helper methods for constructor: Codewords and masking --*/
349
350 // Returns a new byte string representing the given data with the appropriate error correction
351 // codewords appended to it, based on this object's version and error correction level.
352 private addEccAndInterleave(data: Readonly<Array<byte>>): Array<byte> {
353 const ver: int = this.version;
354 const ecl: QrCode.Ecc = this.errorCorrectionLevel;
355 if (data.length != QrCode.getNumDataCodewords(ver, ecl))
356 throw new RangeError("Invalid argument");
357
358 // Calculate parameter numbers
359 const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
360 const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];
361 const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
362 const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks);
363 const shortBlockLen: int = Math.floor(rawCodewords / numBlocks);
364
365 // Split data into blocks and append ECC to each block
366 let blocks: Array<Array<byte>> = [];
367 const rsDiv: Array<byte> = QrCode.reedSolomonComputeDivisor(blockEccLen);
368 for (let i = 0, k = 0; i < numBlocks; i++) {
369 let dat: Array<byte> = data.slice(
370 k,
371 k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1),
372 );
373 k += dat.length;
374 const ecc: Array<byte> = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
375 if (i < numShortBlocks) dat.push(0);
376 blocks.push(dat.concat(ecc));
377 }
378
379 // Interleave (not concatenate) the bytes from every block into a single sequence
380 let result: Array<byte> = [];
381 for (let i = 0; i < blocks[0].length; i++) {
382 blocks.forEach((block, j) => {
383 // Skip the padding byte in short blocks
384 if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]);
385 });
386 }
387 assert(result.length == rawCodewords);
388 return result;
389 }
390
391 // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
392 // data area of this QR Code. Function modules need to be marked off before this is called.
393 private drawCodewords(data: Readonly<Array<byte>>): void {
394 if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
395 throw new RangeError("Invalid argument");
396 let i: int = 0; // Bit index into the data
397 // Do the funny zigzag scan
398 for (let right = this.size - 1; right >= 1; right -= 2) {
399 // Index of right column in each column pair
400 if (right == 6) right = 5;
401 for (let vert = 0; vert < this.size; vert++) {
402 // Vertical counter
403 for (let j = 0; j < 2; j++) {
404 const x: int = right - j; // Actual x coordinate
405 const upward: boolean = ((right + 1) & 2) == 0;
406 const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate
407 if (!this.isFunction[y][x] && i < data.length * 8) {
408 this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
409 i++;
410 }
411 // If this QR Code has any remainder bits (0 to 7), they were assigned as
412 // 0/false/light by the constructor and are left unchanged by this method
413 }
414 }
415 }
416 assert(i == data.length * 8);
417 }
418
419 // XORs the codeword modules in this QR Code with the given mask pattern.
420 // The function modules must be marked and the codeword bits must be drawn
421 // before masking. Due to the arithmetic of XOR, calling applyMask() with
422 // the same mask value a second time will undo the mask. A final well-formed
423 // QR Code needs exactly one (not zero, two, etc.) mask applied.
424 private applyMask(mask: int): void {
425 if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range");
426 for (let y = 0; y < this.size; y++) {
427 for (let x = 0; x < this.size; x++) {
428 let invert: boolean;
429 switch (mask) {
430 case 0:
431 invert = (x + y) % 2 == 0;
432 break;
433 case 1:
434 invert = y % 2 == 0;
435 break;
436 case 2:
437 invert = x % 3 == 0;
438 break;
439 case 3:
440 invert = (x + y) % 3 == 0;
441 break;
442 case 4:
443 invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0;
444 break;
445 case 5:
446 invert = ((x * y) % 2) + ((x * y) % 3) == 0;
447 break;
448 case 6:
449 invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0;
450 break;
451 case 7:
452 invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0;
453 break;
454 default:
455 throw new Error("Unreachable");
456 }
457 if (!this.isFunction[y][x] && invert) this.modules[y][x] = !this.modules[y][x];
458 }
459 }
460 }
461
462 // Calculates and returns the penalty score based on state of this QR Code's current modules.
463 // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
464 private getPenaltyScore(): int {
465 let result: int = 0;
466
467 // Adjacent modules in row having same color, and finder-like patterns
468 for (let y = 0; y < this.size; y++) {
469 let runColor = false;
470 let runX = 0;
471 let runHistory = [0, 0, 0, 0, 0, 0, 0];
472 for (let x = 0; x < this.size; x++) {
473 if (this.modules[y][x] == runColor) {
474 runX++;
475 if (runX == 5) result += QrCode.PENALTY_N1;
476 else if (runX > 5) result++;
477 } else {
478 this.finderPenaltyAddHistory(runX, runHistory);
479 if (!runColor)
480 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
481 runColor = this.modules[y][x];
482 runX = 1;
483 }
484 }
485 result +=
486 this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
487 }
488 // Adjacent modules in column having same color, and finder-like patterns
489 for (let x = 0; x < this.size; x++) {
490 let runColor = false;
491 let runY = 0;
492 let runHistory = [0, 0, 0, 0, 0, 0, 0];
493 for (let y = 0; y < this.size; y++) {
494 if (this.modules[y][x] == runColor) {
495 runY++;
496 if (runY == 5) result += QrCode.PENALTY_N1;
497 else if (runY > 5) result++;
498 } else {
499 this.finderPenaltyAddHistory(runY, runHistory);
500 if (!runColor)
501 result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
502 runColor = this.modules[y][x];
503 runY = 1;
504 }
505 }
506 result +=
507 this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
508 }
509
510 // 2*2 blocks of modules having same color
511 for (let y = 0; y < this.size - 1; y++) {
512 for (let x = 0; x < this.size - 1; x++) {
513 const color: boolean = this.modules[y][x];
514 if (
515 color == this.modules[y][x + 1] &&
516 color == this.modules[y + 1][x] &&
517 color == this.modules[y + 1][x + 1]
518 )
519 result += QrCode.PENALTY_N2;
520 }
521 }
522
523 // Balance of dark and light modules
524 let dark: int = 0;
525 for (const row of this.modules)
526 dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);
527 const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2
528 // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
529 const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
530 assert(0 <= k && k <= 9);
531 result += k * QrCode.PENALTY_N4;
532 assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
533 return result;
534 }
535
536 /*-- Private helper functions --*/
537
538 // Returns an ascending list of positions of alignment patterns for this version number.
539 // Each position is in the range [0,177), and are used on both the x and y axes.
540 // This could be implemented as lookup table of 40 variable-length lists of integers.
541 private getAlignmentPatternPositions(): Array<int> {
542 if (this.version == 1) return [];
543 else {
544 const numAlign: int = Math.floor(this.version / 7) + 2;
545 const step: int =
546 this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
547 let result: Array<int> = [6];
548 for (let pos = this.size - 7; result.length < numAlign; pos -= step)
549 result.splice(1, 0, pos);
550 return result;
551 }
552 }
553
554 // Returns the number of data bits that can be stored in a QR Code of the given version number, after
555 // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
556 // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
557 private static getNumRawDataModules(ver: int): int {
558 if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)
559 throw new RangeError("Version number out of range");
560 let result: int = (16 * ver + 128) * ver + 64;
561 if (ver >= 2) {
562 const numAlign: int = Math.floor(ver / 7) + 2;
563 result -= (25 * numAlign - 10) * numAlign - 55;
564 if (ver >= 7) result -= 36;
565 }
566 assert(208 <= result && result <= 29648);
567 return result;
568 }
569
570 // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
571 // QR Code of the given version number and error correction level, with remainder bits discarded.
572 // This stateless pure function could be implemented as a (40*4)-cell lookup table.
573 private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int {
574 return (
575 Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
576 QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] *
577 QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]
578 );
579 }
580
581 // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
582 // implemented as a lookup table over all possible parameter values, instead of as an algorithm.
583 private static reedSolomonComputeDivisor(degree: int): Array<byte> {
584 if (degree < 1 || degree > 255) throw new RangeError("Degree out of range");
585 // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
586 // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
587 let result: Array<byte> = [];
588 for (let i = 0; i < degree - 1; i++) result.push(0);
589 result.push(1); // Start off with the monomial x^0
590
591 // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
592 // and drop the highest monomial term which is always 1x^degree.
593 // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
594 let root = 1;
595 for (let i = 0; i < degree; i++) {
596 // Multiply the current product by (x - r^i)
597 for (let j = 0; j < result.length; j++) {
598 result[j] = QrCode.reedSolomonMultiply(result[j], root);
599 if (j + 1 < result.length) result[j] ^= result[j + 1];
600 }
601 root = QrCode.reedSolomonMultiply(root, 0x02);
602 }
603 return result;
604 }
605
606 // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
607 private static reedSolomonComputeRemainder(
608 data: Readonly<Array<byte>>,
609 divisor: Readonly<Array<byte>>,
610 ): Array<byte> {
611 let result: Array<byte> = divisor.map((_) => 0);
612 for (const b of data) {
613 // Polynomial division
614 const factor: byte = b ^ (result.shift() as byte);
615 result.push(0);
616 divisor.forEach((coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor)));
617 }
618 return result;
619 }
620
621 // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
622 // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.
623 private static reedSolomonMultiply(x: byte, y: byte): byte {
624 if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range");
625 // Russian peasant multiplication
626 let z: int = 0;
627 for (let i = 7; i >= 0; i--) {
628 z = (z << 1) ^ ((z >>> 7) * 0x11d);
629 z ^= ((y >>> i) & 1) * x;
630 }
631 assert(z >>> 8 == 0);
632 return z as byte;
633 }
634
635 // Can only be called immediately after a light run is added, and
636 // returns either 0, 1, or 2. A helper function for getPenaltyScore().
637 private finderPenaltyCountPatterns(runHistory: Readonly<Array<int>>): int {
638 const n: int = runHistory[1];
639 assert(n <= this.size * 3);
640 const core: boolean =
641 n > 0 &&
642 runHistory[2] == n &&
643 runHistory[3] == n * 3 &&
644 runHistory[4] == n &&
645 runHistory[5] == n;
646 return (
647 (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) +
648 (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0)
649 );
650 }
651
652 // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
653 private finderPenaltyTerminateAndCount(
654 currentRunColor: boolean,
655 currentRunLength: int,
656 runHistory: Array<int>,
657 ): int {
658 if (currentRunColor) {
659 // Terminate dark run
660 this.finderPenaltyAddHistory(currentRunLength, runHistory);
661 currentRunLength = 0;
662 }
663 currentRunLength += this.size; // Add light border to final run
664 this.finderPenaltyAddHistory(currentRunLength, runHistory);
665 return this.finderPenaltyCountPatterns(runHistory);
666 }
667
668 // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
669 private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array<int>): void {
670 if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run
671 runHistory.pop();
672 runHistory.unshift(currentRunLength);
673 }
674
675 /*-- Constants and tables --*/
676
677 // The minimum version number supported in the QR Code Model 2 standard.
678 public static readonly MIN_VERSION: int = 1;
679 // The maximum version number supported in the QR Code Model 2 standard.
680 public static readonly MAX_VERSION: int = 40;
681
682 // For use in getPenaltyScore(), when evaluating which mask is best.
683 private static readonly PENALTY_N1: int = 3;
684 private static readonly PENALTY_N2: int = 3;
685 private static readonly PENALTY_N3: int = 40;
686 private static readonly PENALTY_N4: int = 10;
687
688 private static readonly ECC_CODEWORDS_PER_BLOCK: Array<Array<int>> = [
689 // Version: (note that index 0 is for padding, and is set to an illegal value)
690 //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
691 [
692 -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28,
693 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
694 ], // Low
695 [
696 -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28,
697 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
698 ], // Medium
699 [
700 -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30,
701 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
702 ], // Quartile
703 [
704 -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24,
705 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
706 ], // High
707 ];
708
709 private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array<Array<int>> = [
710 // Version: (note that index 0 is for padding, and is set to an illegal value)
711 //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
712 [
713 -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13,
714 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25,
715 ], // Low
716 [
717 -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21,
718 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49,
719 ], // Medium
720 [
721 -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29,
722 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68,
723 ], // Quartile
724 [
725 -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32,
726 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81,
727 ], // High
728 ];
729 }
730
731 // Appends the given number of low-order bits of the given value
732 // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.
733 function appendBits(val: int, len: int, bb: Array<bit>): void {
734 if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range");
735 for (
736 let i = len - 1;
737 i >= 0;
738 i-- // Append bit by bit
739 )
740 bb.push((val >>> i) & 1);
741 }
742
743 // Returns true iff the i'th bit of x is set to 1.
744 function getBit(x: int, i: int): boolean {
745 return ((x >>> i) & 1) != 0;
746 }
747
748 // Throws an exception if the given condition is false.
749 function assert(cond: boolean): void {
750 if (!cond) throw new Error("Assertion error");
751 }
752
753 /*---- Data segment class ----*/
754
755 /*
756 * A segment of character/binary/control data in a QR Code symbol.
757 * Instances of this class are immutable.
758 * The mid-level way to create a segment is to take the payload data
759 * and call a static factory function such as QrSegment.makeNumeric().
760 * The low-level way to create a segment is to custom-make the bit buffer
761 * and call the QrSegment() constructor with appropriate values.
762 * This segment class imposes no length restrictions, but QR Codes have restrictions.
763 * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
764 * Any segment longer than this is meaningless for the purpose of generating QR Codes.
765 */
766 export class QrSegment {
767 /*-- Static factory functions (mid level) --*/
768
769 // Returns a segment representing the given binary data encoded in
770 // byte mode. All input byte arrays are acceptable. Any text string
771 // can be converted to UTF-8 bytes and encoded as a byte mode segment.
772 public static makeBytes(data: Readonly<Array<byte>>): QrSegment {
773 let bb: Array<bit> = [];
774 for (const b of data) appendBits(b, 8, bb);
775 return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
776 }
777
778 // Returns a segment representing the given string of decimal digits encoded in numeric mode.
779 public static makeNumeric(digits: string): QrSegment {
780 if (!QrSegment.isNumeric(digits))
781 throw new RangeError("String contains non-numeric characters");
782 let bb: Array<bit> = [];
783 for (let i = 0; i < digits.length; ) {
784 // Consume up to 3 digits per iteration
785 const n: int = Math.min(digits.length - i, 3);
786 appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb);
787 i += n;
788 }
789 return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);
790 }
791
792 // Returns a segment representing the given text string encoded in alphanumeric mode.
793 // The characters allowed are: 0 to 9, A to Z (uppercase only), space,
794 // dollar, percent, asterisk, plus, hyphen, period, slash, colon.
795 public static makeAlphanumeric(text: string): QrSegment {
796 if (!QrSegment.isAlphanumeric(text))
797 throw new RangeError("String contains unencodable characters in alphanumeric mode");
798 let bb: Array<bit> = [];
799 let i: int;
800 for (i = 0; i + 2 <= text.length; i += 2) {
801 // Process groups of 2
802 let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;
803 temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));
804 appendBits(temp, 11, bb);
805 }
806 if (i < text.length)
807 // 1 character remaining
808 appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb);
809 return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);
810 }
811
812 // Returns a new mutable list of zero or more segments to represent the given Unicode text string.
813 // The result may use various segment modes and switch modes to optimize the length of the bit stream.
814 public static makeSegments(text: string): Array<QrSegment> {
815 // Select the most efficient segment encoding automatically
816 if (text == "") return [];
817 else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)];
818 else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)];
819 else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];
820 }
821
822 // Returns a segment representing an Extended Channel Interpretation
823 // (ECI) designator with the given assignment value.
824 public static makeEci(assignVal: int): QrSegment {
825 let bb: Array<bit> = [];
826 if (assignVal < 0) throw new RangeError("ECI assignment value out of range");
827 else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb);
828 else if (assignVal < 1 << 14) {
829 appendBits(0b10, 2, bb);
830 appendBits(assignVal, 14, bb);
831 } else if (assignVal < 1000000) {
832 appendBits(0b110, 3, bb);
833 appendBits(assignVal, 21, bb);
834 } else throw new RangeError("ECI assignment value out of range");
835 return new QrSegment(QrSegment.Mode.ECI, 0, bb);
836 }
837
838 // Tests whether the given string can be encoded as a segment in numeric mode.
839 // A string is encodable iff each character is in the range 0 to 9.
840 public static isNumeric(text: string): boolean {
841 return QrSegment.NUMERIC_REGEX.test(text);
842 }
843
844 // Tests whether the given string can be encoded as a segment in alphanumeric mode.
845 // A string is encodable iff each character is in the following set: 0 to 9, A to Z
846 // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
847 public static isAlphanumeric(text: string): boolean {
848 return QrSegment.ALPHANUMERIC_REGEX.test(text);
849 }
850
851 /*-- Constructor (low level) and fields --*/
852
853 // Creates a new QR Code segment with the given attributes and data.
854 // The character count (numChars) must agree with the mode and the bit buffer length,
855 // but the constraint isn't checked. The given bit buffer is cloned and stored.
856 public constructor(
857 // The mode indicator of this segment.
858 public readonly mode: QrSegment.Mode,
859
860 // The length of this segment's unencoded data. Measured in characters for
861 // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
862 // Always zero or positive. Not the same as the data's bit length.
863 public readonly numChars: int,
864
865 // The data bits of this segment. Accessed through getData().
866 private readonly bitData: Array<bit>,
867 ) {
868 if (numChars < 0) throw new RangeError("Invalid argument");
869 this.bitData = bitData.slice(); // Make defensive copy
870 }
871
872 /*-- Methods --*/
873
874 // Returns a new copy of the data bits of this segment.
875 public getData(): Array<bit> {
876 return this.bitData.slice(); // Make defensive copy
877 }
878
879 // (Package-private) Calculates and returns the number of bits needed to encode the given segments at
880 // the given version. The result is infinity if a segment has too many characters to fit its length field.
881 public static getTotalBits(segs: Readonly<Array<QrSegment>>, version: int): number {
882 let result: number = 0;
883 for (const seg of segs) {
884 const ccbits: int = seg.mode.numCharCountBits(version);
885 if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width
886 result += 4 + ccbits + seg.bitData.length;
887 }
888 return result;
889 }
890
891 // Returns a new array of bytes representing the given string encoded in UTF-8.
892 private static toUtf8ByteArray(str: string): Array<byte> {
893 str = encodeURI(str);
894 let result: Array<byte> = [];
895 for (let i = 0; i < str.length; i++) {
896 if (str.charAt(i) != "%") result.push(str.charCodeAt(i));
897 else {
898 result.push(parseInt(str.substring(i + 1, i + 3), 16));
899 i += 2;
900 }
901 }
902 return result;
903 }
904
905 /*-- Constants --*/
906
907 // Describes precisely all strings that are encodable in numeric mode.
908 private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/;
909
910 // Describes precisely all strings that are encodable in alphanumeric mode.
911 private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/;
912
913 // The set of all legal characters in alphanumeric mode,
914 // where each character value maps to the index in the string.
915 private static readonly ALPHANUMERIC_CHARSET: string =
916 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
917 }
918}
919
920/*---- Public helper enumeration ----*/
921
922namespace qrcodegen.QrCode {
923 type int = number;
924
925 /*
926 * The error correction level in a QR Code symbol. Immutable.
927 */
928 export class Ecc {
929 /*-- Constants --*/
930
931 public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords
932 public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords
933 public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords
934 public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords
935
936 /*-- Constructor and fields --*/
937
938 private constructor(
939 // In the range 0 to 3 (unsigned 2-bit integer).
940 public readonly ordinal: int,
941 // (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
942 public readonly formatBits: int,
943 ) {}
944 }
945}
946
947/*---- Public helper enumeration ----*/
948
949namespace qrcodegen.QrSegment {
950 type int = number;
951
952 /*
953 * Describes how a segment's data bits are interpreted. Immutable.
954 */
955 export class Mode {
956 /*-- Constants --*/
957
958 public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]);
959 public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]);
960 public static readonly BYTE = new Mode(0x4, [8, 16, 16]);
961 public static readonly KANJI = new Mode(0x8, [8, 10, 12]);
962 public static readonly ECI = new Mode(0x7, [0, 0, 0]);
963
964 /*-- Constructor and fields --*/
965
966 private constructor(
967 // The mode indicator bits, which is a uint4 value (range 0 to 15).
968 public readonly modeBits: int,
969 // Number of character count bits for three different version ranges.
970 private readonly numBitsCharCount: [int, int, int],
971 ) {}
972
973 /*-- Method --*/
974
975 // (Package-private) Returns the bit width of the character count field for a segment in
976 // this mode in a QR Code at the given version number. The result is in the range [0, 16].
977 public numCharCountBits(ver: int): int {
978 return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
979 }
980 }
981}
982
983export const QrCode = qrcodegen.QrCode;
984export const QrSegment = qrcodegen.QrSegment;