@txco/vite-plugin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
1
+ # @txco/vite-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [Vite](https://vite.dev) plugin for [Thanks, Computer](https://www.thanks.computer)
4
+ (txco). It's for plain Vite apps: React, Vue, Solid, Preact or vanilla, with no
5
+ meta-framework. After `vite build`, it writes a **Web ABI build** beside your
6
+ `dist/`:
7
+
8
+ - a copy of `dist/` as `public/`;
9
+ - the ops a browser-routed app needs, as `ops/`;
10
+ - a `txco-web.json` manifest.
11
+
12
+ `txco apply` installs the build into a stack.
13
+
14
+ ```sh
15
+ npm install -D @txco/vite-plugin
16
+ ```
17
+
18
+ Vite 6, 7 or 8.
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ // vite.config.ts
24
+ import { defineConfig } from "vite";
25
+ import react from "@vitejs/plugin-react";
26
+ import txco from "@txco/vite-plugin";
27
+
28
+ export default defineConfig({
29
+ plugins: [react(), txco()],
30
+ });
31
+ ```
32
+
33
+ Bind the build to a stack in your workspace's `txco.yaml`. The path is
34
+ relative to the workspace root, and must lie outside `OPS/`:
35
+
36
+ ```yaml
37
+ stacks:
38
+ web:
39
+ abi: txco-web
40
+ ```
41
+
42
+ Then build, check and deploy:
43
+
44
+ ```sh
45
+ vite build # writes dist/ as usual, and txco-web/
46
+ txco web check txco-web # installs it on a scratch chassis and probes it
47
+ txco apply # or: txco push web
48
+ ```
49
+
50
+ Gitignore `txco-web/`. It's all build output, and the fallback op embeds
51
+ `index.html`, asset hashes included, so it changes on every build.
52
+
53
+ ## What it writes
54
+
55
+ ```
56
+ txco-web/
57
+ txco-web.json { "abi": 1, "immutable": ["assets/"], … }
58
+ public/ a copy of dist/
59
+ ops/900000/spa-fallback.txcl 200 + index.html, for any page navigation
60
+ ops/900900/not-found.txcl a plain 404 for everything else (an asset miss, a POST)
61
+ ```
62
+
63
+ `dist/` is left as Vite wrote it, so `vite preview` and anything else that
64
+ reads it still works.
65
+
66
+ **A single-page app** (one `index.html`) routes in the browser. Every page
67
+ path that no file or op of yours answers gets `index.html` with status 200.
68
+ The client router renders its own not-found view for a path it doesn't know.
69
+ There's no route table to check, so this is the Web ABI's plain fallback.
70
+
71
+ **A multi-page app** (several HTML inputs in `build.rollupOptions.input`)
72
+ writes `ops/900000/page-404.txcl` instead. A path with no page gets
73
+ `404.html` with status 404 if the build has one, from `public/404.html` or an
74
+ input page, and a plain 404 page if not.
75
+
76
+ **`base`.** With `base: "/app/"` the site lands under `public/app/`, and is
77
+ served at `/app/`. A full-URL `base` (a CDN) loads its assets from there; the
78
+ plugin warns.
79
+
80
+ **Caching.** Vite's hashed output under `assets/` is cached for a year. A
81
+ file you put in `public/assets/` yourself isn't hashed, and the plugin warns
82
+ about it.
83
+
84
+ **Dot paths** (`public/.well-known/…`) never deploy; the plugin warns and
85
+ leaves them out. Library and SSR builds write nothing.
86
+
87
+ ## Options
88
+
89
+ | Option | Default | Meaning |
90
+ | ----------- | -------------- | ------- |
91
+ | `out` | `"txco-web"` | The Web ABI directory to write, relative to the project root. It must be a directory of its own, outside `OPS/` and `dist/`. |
92
+ | `spa` | detected | `true` answers every unknown page path with `index.html` and 200; `false` with the 404 page. |
93
+ | `immutable` | `["assets/"]` | The `public/` prefixes cached for a year. |
94
+ | `scope` | `900000` | The navigation op's scope. The catch-all goes 900 above it. |
95
+
96
+ ## Safety
97
+
98
+ The plugin wipes and rewrites `out` on every build. Before the build starts,
99
+ it refuses an `out` that:
100
+
101
+ - is inside `OPS/`;
102
+ - is inside `dist/`;
103
+ - holds anything that isn't part of a Web ABI build;
104
+ - holds your project.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,14 @@
1
+ /**
2
+ * How a page navigation nothing else answered is handled:
3
+ *
4
+ * - "spa": one page (index.html), which routes in the browser: every page
5
+ * path gets the shell with 200;
6
+ * - "mpa": several pages, each a file: a path with no page gets 404.html
7
+ * (or a built-in 404 page) with 404.
8
+ */
9
+ export type Mode = "spa" | "mpa";
10
+ /** The mode for a build's HTML pages, unless the `spa` option forces it. */
11
+ export declare function chooseMode(pages: readonly string[], spa?: boolean): Mode;
12
+ /** The page an spa build's fallback serves: index.html, or its only page. */
13
+ export declare function shellPage(pages: readonly string[]): string | null;
14
+ export { isDotPath, publicPrefix } from "@txco/web-abi/producer";
package/dist/build.js ADDED
@@ -0,0 +1,16 @@
1
+ // The plugin's decisions about a finished build. Pure, so they're unit-tested.
2
+ /** The mode for a build's HTML pages, unless the `spa` option forces it. */
3
+ export function chooseMode(pages, spa) {
4
+ if (typeof spa === "boolean")
5
+ return spa ? "spa" : "mpa";
6
+ return pages.length > 1 ? "mpa" : "spa";
7
+ }
8
+ /** The page an spa build's fallback serves: index.html, or its only page. */
9
+ export function shellPage(pages) {
10
+ if (pages.includes("index.html"))
11
+ return "index.html";
12
+ return pages.length === 1 ? pages[0] : null;
13
+ }
14
+ // Where the build lands under public/, from `base`, and which paths never
15
+ // deploy: shared with every producer.
16
+ export { isDotPath, publicPrefix } from "@txco/web-abi/producer";
@@ -0,0 +1,18 @@
1
+ import type { Plugin } from "vite";
2
+ export declare const NAME = "@txco/vite-plugin";
3
+ export declare const VERSION = "0.1.0";
4
+ export interface TxcoOptions {
5
+ /** The Web ABI directory to write, relative to the project root. Default "txco-web". */
6
+ out?: string;
7
+ /**
8
+ * Answer every unknown page path with the shell (index.html) and 200. The
9
+ * default: on for a one-page build, off for a multi-page one.
10
+ */
11
+ spa?: boolean;
12
+ /** The public/ prefixes cached for a year. Default: Vite's assetsDir (`assets/`). */
13
+ immutable?: string[];
14
+ /** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
15
+ scope?: number;
16
+ }
17
+ export default function txco(options?: TxcoOptions): Plugin;
18
+ export { chooseMode, publicPrefix } from "./build.js";
package/dist/index.js ADDED
@@ -0,0 +1,124 @@
1
+ // @txco/vite-plugin: after `vite build`, writes a Web ABI build for Thanks,
2
+ // Computer (txco) beside your dist/:
3
+ //
4
+ // txco-web/
5
+ // txco-web.json the manifest
6
+ // public/ a copy of dist/
7
+ // ops/ the navigation op and the catch-all
8
+ //
9
+ // // vite.config.ts
10
+ // import txco from "@txco/vite-plugin";
11
+ // export default defineConfig({ plugins: [txco()] });
12
+ //
13
+ // `txco apply` installs it into the stack txco.yaml binds it to.
14
+ import { readdir, readFile, rm } from "node:fs/promises";
15
+ import { join, relative, resolve } from "node:path";
16
+ import { checkOutDir, copyPublic, listFiles, looksHashed, publicPrefix, renderOps, writeManifest, writeOps, } from "@txco/web-abi/producer";
17
+ import { chooseMode, shellPage } from "./build.js";
18
+ export const NAME = "@txco/vite-plugin";
19
+ export const VERSION = "0.1.0";
20
+ /** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
21
+ const PAGE_WARN_BYTES = 1 << 20;
22
+ export default function txco(options = {}) {
23
+ let config;
24
+ let out = "";
25
+ let skip = false;
26
+ return {
27
+ name: "txco",
28
+ apply: "build",
29
+ enforce: "post",
30
+ // One Web ABI build, from the client build (Vite ≥ 6 asks per environment).
31
+ applyToEnvironment: (env) => env.name === "client",
32
+ async configResolved(c) {
33
+ config = c;
34
+ if (c.build.lib || c.build.ssr) {
35
+ skip = true;
36
+ return;
37
+ }
38
+ out = resolve(c.root, options.out ?? "txco-web");
39
+ const outDir = resolve(c.root, c.build.outDir);
40
+ // Before anything is written: the build wipes and rewrites `out`.
41
+ const problems = checkOutDir({
42
+ dir: out,
43
+ protect: [c.root, outDir, c.publicDir].filter(Boolean),
44
+ notInside: [outDir],
45
+ entries: await readdir(out).catch(() => null),
46
+ });
47
+ if (problems.length > 0)
48
+ throw new Error(`[txco] ${problems.join("\n")}`);
49
+ },
50
+ writeBundle: {
51
+ order: "post",
52
+ sequential: true,
53
+ async handler(_output, bundle) {
54
+ if (skip)
55
+ return;
56
+ const log = config.logger;
57
+ const warn = (msg) => log.warn(`[txco] ${msg}`);
58
+ const outDir = resolve(config.root, config.build.outDir);
59
+ const pages = Object.values(bundle)
60
+ .filter((o) => o.type === "asset" && o.fileName.endsWith(".html"))
61
+ .map((o) => o.fileName)
62
+ .sort();
63
+ const mode = chooseMode(pages, options.spa);
64
+ const { prefix, external } = publicPrefix(config.base);
65
+ if (external)
66
+ warn(`base is ${config.base}: the assets load from there, and the pages are deployed at the root`);
67
+ // dist/ as public/, without what never deploys: .vite/ (build
68
+ // metadata) quietly, any other dot path with a warning.
69
+ await rm(out, { recursive: true, force: true });
70
+ const { skipped: dots } = await copyPublic(outDir, out, prefix);
71
+ if (dots.length > 0)
72
+ warn(`dist/ has ${dots.length} dot path(s) (${dots.slice(0, 3).join(", ")}); they never deploy`);
73
+ const readPage = async (name) => {
74
+ const html = await readFile(join(outDir, name), "utf8").catch(() => null);
75
+ return html === null ? null : { name, html };
76
+ };
77
+ let page;
78
+ if (mode === "spa") {
79
+ const name = shellPage(pages);
80
+ page = name ? await readPage(name) : null;
81
+ if (!page)
82
+ throw new Error(`[txco] a single-page build needs its shell (index.html), and ${outDir} has none`);
83
+ }
84
+ else {
85
+ page = await readPage("404.html");
86
+ }
87
+ if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
88
+ warn(`${page.name} is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
89
+ }
90
+ const ops = renderOps({
91
+ mode: mode === "spa" ? "spa" : "404",
92
+ scope: options.scope,
93
+ producer: NAME,
94
+ page,
95
+ notes: [
96
+ mode === "spa"
97
+ ? "A Vite app routes in the browser and has no route table to check."
98
+ : `This build has ${pages.length} pages, each a file in public/.`,
99
+ ],
100
+ });
101
+ await writeOps(out, ops);
102
+ const files = await listFiles(join(out, "public"));
103
+ const assets = config.build.assetsDir.replace(/^\/+|\/+$/g, "");
104
+ const immutable = options.immutable ?? (assets && files.some((f) => f.startsWith(`${prefix}${assets}/`)) ? [`${prefix}${assets}/`] : []);
105
+ for (const p of immutable) {
106
+ const unhashed = files.filter((f) => f.startsWith(p) && !looksHashed(f.split("/").pop()));
107
+ if (unhashed.length > 0) {
108
+ warn(`${unhashed.length} file(s) under the immutable prefix ${p} carry no content hash (${unhashed.slice(0, 3).join(", ")}), so a change to them wouldn't reach a browser for a year`);
109
+ }
110
+ }
111
+ await writeManifest(out, {
112
+ abi: 1,
113
+ ...(immutable.length > 0 ? { immutable } : {}),
114
+ "x-producer": { name: NAME, version: VERSION },
115
+ "x-vite": { version: this.meta.viteVersion, mode, pages: pages.length },
116
+ });
117
+ const shown = relative(process.cwd(), out) || ".";
118
+ log.info(`[txco] Web ABI build (${mode}) at ${shown}: ${files.length} public file(s), ${Object.keys(ops).length} op(s). ` +
119
+ `Next: \`txco web check ${shown}\`, bind it in txco.yaml (stacks: <name>: abi: ${shown}), then \`txco apply\`.`);
120
+ },
121
+ },
122
+ };
123
+ }
124
+ export { chooseMode, publicPrefix } from "./build.js";
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@txco/vite-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Vite plugin for Thanks, Computer (txco): after `vite build`, writes a Web ABI build — your dist/ as public/, the ops a browser-routed app needs as ops/, and txco-web.json — for `txco apply` to install.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "keywords": [
9
+ "vite",
10
+ "vite-plugin",
11
+ "spa",
12
+ "txco",
13
+ "thanks-computer",
14
+ "static",
15
+ "web-abi"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/LoremLabs/thanks-computer.git",
20
+ "directory": "sdk/vite-plugin"
21
+ },
22
+ "homepage": "https://github.com/LoremLabs/thanks-computer/tree/master/sdk/vite-plugin#readme",
23
+ "bugs": "https://github.com/LoremLabs/thanks-computer/issues",
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "scripts": {
42
+ "clean": "rm -rf dist",
43
+ "build": "tsc -p tsconfig.json",
44
+ "prepublishOnly": "npm run clean && npm run build",
45
+ "test": "npm run build && node --test test/*.test.mjs"
46
+ },
47
+ "dependencies": {
48
+ "@txco/web-abi": "^0.2.0"
49
+ },
50
+ "peerDependencies": {
51
+ "vite": "^6.1.0 || ^7.0.0 || ^8.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "typescript": "^5.4.0",
56
+ "vite": "^8.3.0"
57
+ }
58
+ }
package/src/build.ts ADDED
@@ -0,0 +1,27 @@
1
+ // The plugin's decisions about a finished build. Pure, so they're unit-tested.
2
+
3
+ /**
4
+ * How a page navigation nothing else answered is handled:
5
+ *
6
+ * - "spa": one page (index.html), which routes in the browser: every page
7
+ * path gets the shell with 200;
8
+ * - "mpa": several pages, each a file: a path with no page gets 404.html
9
+ * (or a built-in 404 page) with 404.
10
+ */
11
+ export type Mode = "spa" | "mpa";
12
+
13
+ /** The mode for a build's HTML pages, unless the `spa` option forces it. */
14
+ export function chooseMode(pages: readonly string[], spa?: boolean): Mode {
15
+ if (typeof spa === "boolean") return spa ? "spa" : "mpa";
16
+ return pages.length > 1 ? "mpa" : "spa";
17
+ }
18
+
19
+ /** The page an spa build's fallback serves: index.html, or its only page. */
20
+ export function shellPage(pages: readonly string[]): string | null {
21
+ if (pages.includes("index.html")) return "index.html";
22
+ return pages.length === 1 ? pages[0] : null;
23
+ }
24
+
25
+ // Where the build lands under public/, from `base`, and which paths never
26
+ // deploy: shared with every producer.
27
+ export { isDotPath, publicPrefix } from "@txco/web-abi/producer";
package/src/index.ts ADDED
@@ -0,0 +1,159 @@
1
+ // @txco/vite-plugin: after `vite build`, writes a Web ABI build for Thanks,
2
+ // Computer (txco) beside your dist/:
3
+ //
4
+ // txco-web/
5
+ // txco-web.json the manifest
6
+ // public/ a copy of dist/
7
+ // ops/ the navigation op and the catch-all
8
+ //
9
+ // // vite.config.ts
10
+ // import txco from "@txco/vite-plugin";
11
+ // export default defineConfig({ plugins: [txco()] });
12
+ //
13
+ // `txco apply` installs it into the stack txco.yaml binds it to.
14
+ import { readdir, readFile, rm } from "node:fs/promises";
15
+ import { join, relative, resolve } from "node:path";
16
+
17
+ import {
18
+ checkOutDir,
19
+ copyPublic,
20
+ listFiles,
21
+ looksHashed,
22
+ publicPrefix,
23
+ renderOps,
24
+ writeManifest,
25
+ writeOps,
26
+ } from "@txco/web-abi/producer";
27
+ import type { Plugin, ResolvedConfig } from "vite";
28
+
29
+ import { chooseMode, shellPage } from "./build.js";
30
+
31
+ export const NAME = "@txco/vite-plugin";
32
+ export const VERSION = "0.1.0";
33
+ /** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
34
+ const PAGE_WARN_BYTES = 1 << 20;
35
+
36
+ export interface TxcoOptions {
37
+ /** The Web ABI directory to write, relative to the project root. Default "txco-web". */
38
+ out?: string;
39
+ /**
40
+ * Answer every unknown page path with the shell (index.html) and 200. The
41
+ * default: on for a one-page build, off for a multi-page one.
42
+ */
43
+ spa?: boolean;
44
+ /** The public/ prefixes cached for a year. Default: Vite's assetsDir (`assets/`). */
45
+ immutable?: string[];
46
+ /** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
47
+ scope?: number;
48
+ }
49
+
50
+ export default function txco(options: TxcoOptions = {}): Plugin {
51
+ let config: ResolvedConfig;
52
+ let out = "";
53
+ let skip = false;
54
+
55
+ return {
56
+ name: "txco",
57
+ apply: "build",
58
+ enforce: "post",
59
+ // One Web ABI build, from the client build (Vite ≥ 6 asks per environment).
60
+ applyToEnvironment: (env) => env.name === "client",
61
+
62
+ async configResolved(c) {
63
+ config = c;
64
+ if (c.build.lib || c.build.ssr) {
65
+ skip = true;
66
+ return;
67
+ }
68
+ out = resolve(c.root, options.out ?? "txco-web");
69
+ const outDir = resolve(c.root, c.build.outDir);
70
+ // Before anything is written: the build wipes and rewrites `out`.
71
+ const problems = checkOutDir({
72
+ dir: out,
73
+ protect: [c.root, outDir, c.publicDir].filter(Boolean),
74
+ notInside: [outDir],
75
+ entries: await readdir(out).catch(() => null),
76
+ });
77
+ if (problems.length > 0) throw new Error(`[txco] ${problems.join("\n")}`);
78
+ },
79
+
80
+ writeBundle: {
81
+ order: "post",
82
+ sequential: true,
83
+ async handler(_output, bundle) {
84
+ if (skip) return;
85
+ const log = config.logger;
86
+ const warn = (msg: string) => log.warn(`[txco] ${msg}`);
87
+ const outDir = resolve(config.root, config.build.outDir);
88
+
89
+ const pages = Object.values(bundle)
90
+ .filter((o) => o.type === "asset" && o.fileName.endsWith(".html"))
91
+ .map((o) => o.fileName)
92
+ .sort();
93
+ const mode = chooseMode(pages, options.spa);
94
+ const { prefix, external } = publicPrefix(config.base);
95
+ if (external) warn(`base is ${config.base}: the assets load from there, and the pages are deployed at the root`);
96
+
97
+ // dist/ as public/, without what never deploys: .vite/ (build
98
+ // metadata) quietly, any other dot path with a warning.
99
+ await rm(out, { recursive: true, force: true });
100
+ const { skipped: dots } = await copyPublic(outDir, out, prefix);
101
+ if (dots.length > 0) warn(`dist/ has ${dots.length} dot path(s) (${dots.slice(0, 3).join(", ")}); they never deploy`);
102
+
103
+ const readPage = async (name: string) => {
104
+ const html = await readFile(join(outDir, name), "utf8").catch(() => null);
105
+ return html === null ? null : { name, html };
106
+ };
107
+ let page;
108
+ if (mode === "spa") {
109
+ const name = shellPage(pages);
110
+ page = name ? await readPage(name) : null;
111
+ if (!page) throw new Error(`[txco] a single-page build needs its shell (index.html), and ${outDir} has none`);
112
+ } else {
113
+ page = await readPage("404.html");
114
+ }
115
+ if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
116
+ warn(`${page.name} is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
117
+ }
118
+
119
+ const ops = renderOps({
120
+ mode: mode === "spa" ? "spa" : "404",
121
+ scope: options.scope,
122
+ producer: NAME,
123
+ page,
124
+ notes: [
125
+ mode === "spa"
126
+ ? "A Vite app routes in the browser and has no route table to check."
127
+ : `This build has ${pages.length} pages, each a file in public/.`,
128
+ ],
129
+ });
130
+ await writeOps(out, ops);
131
+
132
+ const files = await listFiles(join(out, "public"));
133
+ const assets = config.build.assetsDir.replace(/^\/+|\/+$/g, "");
134
+ const immutable = options.immutable ?? (assets && files.some((f) => f.startsWith(`${prefix}${assets}/`)) ? [`${prefix}${assets}/`] : []);
135
+ for (const p of immutable) {
136
+ const unhashed = files.filter((f) => f.startsWith(p) && !looksHashed(f.split("/").pop()!));
137
+ if (unhashed.length > 0) {
138
+ warn(`${unhashed.length} file(s) under the immutable prefix ${p} carry no content hash (${unhashed.slice(0, 3).join(", ")}), so a change to them wouldn't reach a browser for a year`);
139
+ }
140
+ }
141
+
142
+ await writeManifest(out, {
143
+ abi: 1,
144
+ ...(immutable.length > 0 ? { immutable } : {}),
145
+ "x-producer": { name: NAME, version: VERSION },
146
+ "x-vite": { version: this.meta.viteVersion, mode, pages: pages.length },
147
+ });
148
+
149
+ const shown = relative(process.cwd(), out) || ".";
150
+ log.info(
151
+ `[txco] Web ABI build (${mode}) at ${shown}: ${files.length} public file(s), ${Object.keys(ops).length} op(s). ` +
152
+ `Next: \`txco web check ${shown}\`, bind it in txco.yaml (stacks: <name>: abi: ${shown}), then \`txco apply\`.`,
153
+ );
154
+ },
155
+ },
156
+ };
157
+ }
158
+
159
+ export { chooseMode, publicPrefix } from "./build.js";