@uniflowed/host 0.0.0-alpha.8 → 0.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/assets.js +248 -0
  2. package/package.json +3 -1
package/assets.js ADDED
@@ -0,0 +1,248 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: this runs in the host that runs Vite, beside
4
+ // `transform.js`, and reaching the Flow transform is what that file is for.
5
+ //
6
+ // The JavaScript side of the `uf assets` service.
7
+ //
8
+ // Decoding, resizing and re-encoding images lives in `crates/uf_assets` and is
9
+ // reached exactly the way the Flow transform is reached: one long-lived `uf`
10
+ // process per host process, newline-delimited JSON in, replies in request
11
+ // order out. The protocol is documented on the other side, in
12
+ // `crates/uf_cli/src/commands/assets.rs`.
13
+ //
14
+ // Why a second service rather than a second message on the transform one: the
15
+ // transform service runs on a thread with half a gigabyte of stack because
16
+ // every stage of the Flow chain recurses, and it is started for every host
17
+ // that touches a module — the Node loader hook and the Bun preload included,
18
+ // neither of which has any use for an image pipeline. A build that imports no
19
+ // images should not start a process that can resize them, and a `uf test` run
20
+ // should not start one at all.
21
+
22
+ import { spawn } from "node:child_process";
23
+ import { createInterface } from "node:readline";
24
+
25
+ import { ufBinary, ufBinaryIdentity } from "./transform.js";
26
+
27
+ /**
28
+ * File extensions the pipeline claims.
29
+ *
30
+ * `.svg` is deliberately not among them. uf could only ever copy one through —
31
+ * it is already resolution independent, so there is nothing to resize — and
32
+ * claiming it would take `.svg` away from `vite-plugin-svgr` and everything
33
+ * like it, which turn one into a component. A plugin that claims an extension
34
+ * to do nothing with it is the shape of red line 8 in `docs/red-lines.md`: if
35
+ * Vite can do it, a uf project can do it. So an SVG import stays Vite's, and
36
+ * `<Image src={url} width={…} height={…} />` is how you render one.
37
+ *
38
+ * `.gif` and `.avif` *are* claimed even though no decoder for them is compiled
39
+ * in, and that is the opposite decision for a reason: uf has something to say
40
+ * about them. The import still evaluates to a manifest, and the manifest
41
+ * carries a `note` saying the file was served unchanged and why — which is
42
+ * what tells an author their AVIF is not being resized, rather than leaving
43
+ * them to notice.
44
+ */
45
+ export const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"];
46
+
47
+ /** Font file extensions the pipeline claims. */
48
+ export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"];
49
+
50
+ /**
51
+ * Whether this import is one uf's asset pipeline handles, and as what.
52
+ *
53
+ * Returns `"image"`, `"font"`, or `null`. The query string is stripped first:
54
+ * `./hero.png?width=400` is an image, and the query is how an import says what
55
+ * it wants.
56
+ */
57
+ export function assetKind(id) {
58
+ const clean = stripQuery(id);
59
+ const lower = clean.toLowerCase();
60
+ if (IMAGE_EXTENSIONS.some((extension) => lower.endsWith(extension))) return "image";
61
+ if (FONT_EXTENSIONS.some((extension) => lower.endsWith(extension))) return "font";
62
+ return null;
63
+ }
64
+
65
+ function stripQuery(id) {
66
+ const at = id.indexOf("?");
67
+ return at === -1 ? id : id.slice(0, at);
68
+ }
69
+
70
+ /** An asset the pipeline could not process. */
71
+ export class AssetError extends Error {
72
+ constructor(id, message) {
73
+ super(message);
74
+ this.name = "AssetError";
75
+ this.id = id;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * One `uf assets` process, with requests answered in the order they were sent.
81
+ *
82
+ * The same arrangement as `TransformService` next door, including the reason
83
+ * there are no correlation ids: the service replies once per request and in
84
+ * order, so a plain queue of resolvers pairs a reply with its caller. Any exit
85
+ * is final and every outstanding request is rejected at once.
86
+ */
87
+ export class AssetService {
88
+ #child;
89
+ #pending = [];
90
+ #identity;
91
+ #failure = null;
92
+
93
+ /**
94
+ * @param {object} [options]
95
+ * @param {string} [options.command] the `uf` binary; `ufBinary()` by default
96
+ * @param {string} [options.root] project root, so `uf.config.js` is found
97
+ */
98
+ constructor(options = {}) {
99
+ const command = options.command ?? ufBinary();
100
+ const root = options.root ?? process.cwd();
101
+ // Read before the spawn and kept, for the reason `TransformService` gives:
102
+ // the child goes on executing the binary it started from however many
103
+ // times that file is rewritten underneath it, and anything cached from an
104
+ // answer of this service belongs under *this* identity.
105
+ this.#identity = ufBinaryIdentity(command);
106
+ this.#child = spawn(command, ["--cwd", root, "assets"], {
107
+ stdio: ["pipe", "pipe", "inherit"],
108
+ });
109
+
110
+ createInterface({ input: this.#child.stdout }).on("line", (line) => {
111
+ const waiting = this.#pending.shift();
112
+ if (!waiting) return;
113
+ let reply;
114
+ try {
115
+ reply = JSON.parse(line);
116
+ } catch {
117
+ waiting.reject(new Error(`uf assets sent a malformed reply: ${line}`));
118
+ return;
119
+ }
120
+ if (reply.error != null) {
121
+ waiting.reject(new AssetError(waiting.id, reply.error));
122
+ return;
123
+ }
124
+ waiting.resolve(reply);
125
+ });
126
+
127
+ this.#child.on("error", (error) => {
128
+ this.#settleAll(new Error(`could not run \`${command} assets\`: ${error.message}`));
129
+ });
130
+ this.#child.on("close", (code) => {
131
+ this.#settleAll(new Error(`uf assets exited (${code})`));
132
+ });
133
+ }
134
+
135
+ #send(request) {
136
+ if (this.#failure) return Promise.reject(this.#failure);
137
+ return new Promise((resolve, reject) => {
138
+ this.#pending.push({ id: request.id, resolve, reject });
139
+ this.#child.stdin.write(`${JSON.stringify(request)}\n`);
140
+ });
141
+ }
142
+
143
+ #settleAll(error) {
144
+ this.#failure = error;
145
+ while (this.#pending.length > 0) this.#pending.shift().reject(error);
146
+ }
147
+
148
+ /**
149
+ * Resize and re-encode one image.
150
+ *
151
+ * Resolves to the manifest the component reads: `{ width, height, format,
152
+ * variants, blur, declined, note }`. Every field is named here rather than
153
+ * passed through, which is deliberate and is the bug `transform.js` records
154
+ * next door: a shim that copies three of four fields drops the fourth
155
+ * silently, and the caller sees `undefined` rather than an error.
156
+ *
157
+ * @param {string} id absolute path to the source image
158
+ * @param {object} options
159
+ * @param {string} options.outDir where variants are written
160
+ * @param {number[]} [options.widths] overriding the project's
161
+ * @param {number} [options.quality]
162
+ * @param {boolean} [options.blur]
163
+ */
164
+ async image(id, options) {
165
+ const reply = await this.#send({
166
+ kind: "image",
167
+ id,
168
+ outDir: options.outDir,
169
+ widths: options.widths,
170
+ quality: options.quality,
171
+ blur: options.blur,
172
+ });
173
+ const image = reply.image;
174
+ if (image == null) throw new AssetError(id, "uf assets returned no image");
175
+ return {
176
+ width: image.width ?? null,
177
+ height: image.height ?? null,
178
+ format: image.format,
179
+ variants: image.variants ?? [],
180
+ blur: image.blur ?? null,
181
+ declined: image.declined ?? [],
182
+ note: image.note ?? null,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Self-host one font and describe it.
188
+ *
189
+ * Resolves to `{ file, mime, bytes, family, fallbackFamily, container,
190
+ * metrics, fallback, fallbackDeclined, css }`.
191
+ *
192
+ * @param {string} id absolute path to the source font
193
+ * @param {object} options
194
+ * @param {string} options.outDir where the copy is written
195
+ * @param {string} [options.family]
196
+ * @param {string} [options.weight]
197
+ * @param {string} [options.style]
198
+ * @param {string} [options.display]
199
+ * @param {string} [options.baseUrl] prefixed to the file name in `src: url()`
200
+ * @param {string | null} [options.fallback] `null` for no fallback face
201
+ */
202
+ async font(id, options) {
203
+ const request = {
204
+ kind: "font",
205
+ id,
206
+ outDir: options.outDir,
207
+ family: options.family,
208
+ weight: options.weight,
209
+ style: options.style,
210
+ display: options.display,
211
+ baseUrl: options.baseUrl,
212
+ };
213
+ // Only sent when the caller had an opinion. The service distinguishes "not
214
+ // mentioned, use the project's" from "explicitly none", and a key that is
215
+ // always present collapses the two.
216
+ if ("fallback" in options) request.fallback = options.fallback;
217
+ const reply = await this.#send(request);
218
+ const font = reply.font;
219
+ if (font == null) throw new AssetError(id, "uf assets returned no font");
220
+ return {
221
+ file: font.file,
222
+ mime: font.mime,
223
+ bytes: font.bytes,
224
+ family: font.family,
225
+ fallbackFamily: font.fallbackFamily ?? null,
226
+ container: font.container,
227
+ metrics: font.metrics,
228
+ fallback: font.fallback ?? null,
229
+ fallbackDeclined: font.fallbackDeclined ?? null,
230
+ css: font.css,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The build of `uf` this service's child is executing, or `null`.
236
+ *
237
+ * @returns {string | null}
238
+ */
239
+ get identity() {
240
+ return this.#identity;
241
+ }
242
+
243
+ /** Stop the process. Outstanding requests are rejected. */
244
+ close() {
245
+ this.#child.stdin.end();
246
+ this.#child.kill();
247
+ }
248
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/host",
3
- "version": "0.0.0-alpha.8",
3
+ "version": "0.0.0-alpha.9",
4
4
  "description": "Running Flow on a Capability JS Host, with no bundler in the way.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "directory": "packages/host"
12
12
  },
13
13
  "exports": {
14
+ "./assets": "./assets.js",
14
15
  "./bun-preload": "./bun-preload.js",
15
16
  "./internal/node-hooks.js": "./internal/node-hooks.js",
16
17
  "./module-mocks": "./module-mocks.js",
@@ -20,6 +21,7 @@
20
21
  },
21
22
  "files": [
22
23
  "register.js",
24
+ "assets.js",
23
25
  "bun-preload.js",
24
26
  "module-mocks.js",
25
27
  "transform.js",