acrylblends 0.7.6 → 0.7.8
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/dist/index.d.ts +1 -1
- package/dist/index.js +148 -24
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +1 -1
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/agents/index.md +18 -3
- package/docs/framework/getting-started/desktop.md +2 -2
- package/docs/framework/guides/client-modules.md +4 -3
- package/docs/framework/guides/data.md +72 -8
- package/docs/framework/helpers/streaming.md +12 -27
- package/docs/framework/upstream.json +2 -2
- package/lib/browser/read-sse.js +84 -0
- package/lib/index-BLTYsyc1.d.ts +93 -0
- package/lib/kv/db.d.ts +12 -0
- package/lib/kv/db.js +57 -0
- package/lib/kv/json.d.ts +11 -0
- package/lib/kv/json.js +224 -0
- package/lib/kv.d.ts +2 -0
- package/lib/kv.js +2 -0
- package/lib/node-server.d.ts +3 -3
- package/lib/node-server.js +13 -8
- package/lib/read-sse.d.ts +33 -0
- package/lib/read-sse.js +84 -0
- package/lib/src-DRFVEQQb.js +90 -0
- package/package.json +17 -1
- package/plugins/acryl-db/plugin.json +1 -1
- package/plugins/acryl-db/upstream.json +2 -2
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/LICENSE +47 -0
- package/plugins/acryl-kv/plugin.json +12 -0
- package/plugins/acryl-kv/src/check.ts +58 -0
- package/plugins/acryl-kv/src/copy.ts +39 -0
- package/plugins/acryl-kv/src/cordis.ts +13 -0
- package/plugins/acryl-kv/src/index.ts +14 -0
- package/plugins/acryl-kv/src/kv.ts +37 -0
- package/plugins/acryl-kv/upstream.json +18 -0
- package/plugins/acryl-kv-db/LICENSE +47 -0
- package/plugins/acryl-kv-db/plugin.json +13 -0
- package/plugins/acryl-kv-db/src/index.ts +37 -0
- package/plugins/acryl-kv-db/src/open.ts +80 -0
- package/plugins/acryl-kv-db/upstream.json +21 -0
- package/plugins/acryl-kv-json/LICENSE +47 -0
- package/plugins/acryl-kv-json/plugin.json +13 -0
- package/plugins/acryl-kv-json/src/index.ts +71 -0
- package/plugins/acryl-kv-json/src/open.ts +249 -0
- package/plugins/acryl-kv-json/upstream.json +22 -0
- package/plugins/acryl-node-server/src/client-modules.ts +10 -9
- package/plugins/acryl-node-server/src/index.ts +12 -1
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/builder/index.ts +2 -2
- package/plugins/builder/plugin.json +1 -1
- package/plugins/desktop/main.ts +3 -3
- package/plugins/desktop/plugin.json +2 -2
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +2 -2
- package/plugins/desktop-web/plugin.json +2 -2
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/index.ts +19 -1
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +1 -1
- package/plugins/manifest/lock.ts +7 -3
- package/plugins/manifest/upgrade-cli.ts +1 -1
- package/plugins/save/cli.ts +1 -1
- package/plugins/save/plugin.json +1 -1
- package/plugins/tsconfig.json +2 -0
- package/shared/AGENTS.core.md +10 -10
- package/shared/DEFAULTS.md +40 -0
- package/shared/SKILL.md +14 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/README.md +6 -6
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +8 -8
- package/templates/agent/src/app.ts +28 -15
- package/templates/agent/src/main.ts +2 -2
- package/templates/agent/src/routes.ts +5 -5
- package/templates/agent/test/agent.test.ts +1 -1
- package/templates/agent/test/app.test.ts +1 -1
- package/templates/agent/test/builder.test.ts +1 -1
- package/templates/agent/test/model.test.ts +1 -1
- package/templates/agent/tsconfig.json +48 -50
- package/templates/blank/AGENTS.md +1 -1
- package/templates/blank/README.md +2 -2
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +8 -8
- package/templates/blank/src/about.tsx +2 -2
- package/templates/blank/src/app.ts +4 -4
- package/templates/blank/src/main.ts +1 -1
- package/templates/blank/src/routes.ts +1 -1
- package/templates/blank/test/app.test.ts +1 -1
- package/templates/blank/tsconfig.json +1 -1
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +8 -8
- package/templates/todo/src/app.ts +5 -5
- package/templates/todo/src/main.ts +1 -1
- package/templates/todo/src/todo.ts +4 -4
- package/templates/todo/test/todo.test.ts +1 -1
- package/templates/todo/tsconfig.json +1 -1
- package/templates/todo/upstream.json +2 -2
|
@@ -86,7 +86,7 @@ app.get('/sse', async (c) => {
|
|
|
86
86
|
A reader that closes the tab, loses the network or presses a Stop button should not leave your server working for nobody (a request to a paid model, a database scan). There are two signals, and they are not the same:
|
|
87
87
|
|
|
88
88
|
- **`c.req.raw.signal`** is the request's `AbortSignal`. It aborts when the client goes away. Pass it to anything that takes a `signal`, such as `fetch`, and the work stops with the client. This is the one to use for cancelling work.
|
|
89
|
-
- **`stream.onAbort(callback)`** and **`stream.aborted`** belong to the response stream. They report that the *reader of the response* was cancelled.
|
|
89
|
+
- **`stream.onAbort(callback)`** and **`stream.aborted`** belong to the response stream. They report that the *reader of the response* was cancelled, and they are set when the response body is cancelled, which is what the Node adapter does when the connection closes. Over a real connection, a browser that aborts its `fetch` (or closes the tab) fires both: the request signal and `stream.onAbort`. Under `app.request()` (in-process, no socket) they come apart, and this is how the upstream behaves too: aborting the `signal` you passed to `app.request()` fires `c.req.raw.signal` while `stream.aborted` stays `false`, and cancelling the reader of the response body fires `stream.onAbort` while `c.req.raw.signal` does not. A test that simulates the client leaving therefore aborts the request signal (for work cancelled through the signal) or cancels the body reader (for `stream.onAbort`), whichever the handler listens to. Use `c.req.raw.signal` for the work, and `stream.onAbort` for cleanup of the stream itself.
|
|
90
90
|
|
|
91
91
|
An abort event that has already fired does not fire again, so check `signal.aborted` before you wait for it:
|
|
92
92
|
|
|
@@ -160,31 +160,10 @@ app.post('/api/chat', async (c) => {
|
|
|
160
160
|
|
|
161
161
|
## Reading a POST event stream in the browser
|
|
162
162
|
|
|
163
|
-
`EventSource` can only send a GET request. For a POST (a chat message), read the response
|
|
163
|
+
`EventSource` can only send a GET request. For a POST (a chat message), call `fetch` and read the events with `readSSE` from `acrylblends/read-sse`. It is an async iterator over the events of a `text/event-stream` response, parsed by the rules of the SSE standard (LF, CRLF and CR line ends, several `data` lines joined with a line feed, comments, UTF-8 split across chunks):
|
|
164
164
|
|
|
165
165
|
```ts
|
|
166
|
-
|
|
167
|
-
const reader = body.getReader()
|
|
168
|
-
const decoder = new TextDecoder()
|
|
169
|
-
let buffer = ''
|
|
170
|
-
for (;;) {
|
|
171
|
-
const { done, value } = await reader.read()
|
|
172
|
-
if (done) return
|
|
173
|
-
// Line endings may be CRLF; a \r at the end of a chunk is completed by the next chunk, so normalise the whole buffer.
|
|
174
|
-
buffer = (buffer + decoder.decode(value, { stream: true })).replaceAll('\r\n', '\n')
|
|
175
|
-
const blocks = buffer.split('\n\n')
|
|
176
|
-
buffer = blocks.pop() ?? ''
|
|
177
|
-
for (const block of blocks) {
|
|
178
|
-
let event = 'message'
|
|
179
|
-
const data: string[] = []
|
|
180
|
-
for (const line of block.split('\n')) {
|
|
181
|
-
if (line.startsWith('event:')) event = line.slice(6).trim()
|
|
182
|
-
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) // the SSE spec strips one leading space only
|
|
183
|
-
}
|
|
184
|
-
on(event, data.join('\n'))
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
166
|
+
import { readSSE } from 'acrylblends/read-sse'
|
|
188
167
|
|
|
189
168
|
const controller = new AbortController() // controller.abort() is the Stop button
|
|
190
169
|
const response = await fetch('/api/chat', {
|
|
@@ -193,13 +172,19 @@ const response = await fetch('/api/chat', {
|
|
|
193
172
|
body: JSON.stringify({ message: 'Hello' }),
|
|
194
173
|
signal: controller.signal,
|
|
195
174
|
})
|
|
175
|
+
|
|
196
176
|
let answer = ''
|
|
197
|
-
await
|
|
177
|
+
for await (const { event, data } of readSSE(response)) {
|
|
198
178
|
if (event === 'token') answer += JSON.parse(data) as string
|
|
199
|
-
}
|
|
179
|
+
}
|
|
200
180
|
```
|
|
201
181
|
|
|
202
|
-
|
|
182
|
+
- Each event is `{ event, data, id?, retry? }`; `event` is `message` when the server named none, and `data` is a string (parse it yourself when the server sends JSON).
|
|
183
|
+
- Leaving the loop early (a `break`, a thrown error) cancels the response body, so the request to the server ends. Abort the `fetch` with its own `signal` for a Stop button; `readSSE(response, { signal })` also stops reading when a signal aborts.
|
|
184
|
+
- A response that is not `ok` throws with its status (the body is not parsed as events), and a response without a body throws.
|
|
185
|
+
- A client module served by `clientModules` can import it as it is (`import { readSSE } from 'acrylblends/read-sse'`): the import is pointed at a browser file the same app serves, like `acrylblends/client`. With a bundler it is an ordinary import.
|
|
186
|
+
|
|
187
|
+
A complete chat component is in the `chat` template of `create-acrylblends` (`npm create acrylblends@latest my-chat -- --template chat`).
|
|
203
188
|
|
|
204
189
|
## Error Handling
|
|
205
190
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"source": {
|
|
3
3
|
"repo": "acryldev/acryl",
|
|
4
4
|
"path": "docs/framework",
|
|
5
|
-
"commit": "
|
|
5
|
+
"commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
|
|
6
6
|
},
|
|
7
7
|
"files": 99,
|
|
8
|
-
"digest": "sha256:
|
|
8
|
+
"digest": "sha256:9b0fe3329319398ce1108a015a99247c001b02cfb30a953b4b180a73674e2aec"
|
|
9
9
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//#region ../../plugins/acryl-router/src/helpers/read-sse.ts
|
|
2
|
+
/**
|
|
3
|
+
* Read the events of a `text/event-stream` response as they arrive. Leaving the loop early (`break`, a thrown error) cancels the body, so the
|
|
4
|
+
* request to the server ends.
|
|
5
|
+
* @throws when the response is not `ok` (the status is in the message; the body is cancelled, not parsed), has no body, or fails while it is read.
|
|
6
|
+
*/
|
|
7
|
+
async function* readSSE(response, options = {}) {
|
|
8
|
+
if (!response.ok) {
|
|
9
|
+
await response.body?.cancel().catch(() => void 0);
|
|
10
|
+
throw new Error(`readSSE: the request failed with ${String(response.status)}${response.statusText === "" ? "" : ` ${response.statusText}`}`);
|
|
11
|
+
}
|
|
12
|
+
if (response.body === null) throw new Error("readSSE: the response has no body to read");
|
|
13
|
+
const { signal } = options;
|
|
14
|
+
if (signal?.aborted === true) {
|
|
15
|
+
await response.body.cancel().catch(() => void 0);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const reader = response.body.getReader();
|
|
19
|
+
const onAbort = () => {
|
|
20
|
+
reader.cancel().catch(() => void 0);
|
|
21
|
+
};
|
|
22
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
23
|
+
const decoder = new TextDecoder("utf-8");
|
|
24
|
+
let buffer = "";
|
|
25
|
+
let skipLineFeed = false;
|
|
26
|
+
let name = "";
|
|
27
|
+
let data = [];
|
|
28
|
+
let id;
|
|
29
|
+
let retry;
|
|
30
|
+
/** Apply one line; returns the event when this line completes one. */
|
|
31
|
+
const apply = (line) => {
|
|
32
|
+
if (line === "") {
|
|
33
|
+
const event = data.length === 0 ? void 0 : {
|
|
34
|
+
event: name === "" ? "message" : name,
|
|
35
|
+
data: data.join("\n"),
|
|
36
|
+
...id === void 0 ? {} : { id },
|
|
37
|
+
...retry === void 0 ? {} : { retry }
|
|
38
|
+
};
|
|
39
|
+
name = "";
|
|
40
|
+
data = [];
|
|
41
|
+
id = void 0;
|
|
42
|
+
retry = void 0;
|
|
43
|
+
return event;
|
|
44
|
+
}
|
|
45
|
+
if (line.startsWith(":")) return void 0;
|
|
46
|
+
const colon = line.indexOf(":");
|
|
47
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
48
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
49
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
50
|
+
if (field === "event") name = value;
|
|
51
|
+
else if (field === "data") data.push(value);
|
|
52
|
+
else if (field === "id") {
|
|
53
|
+
if (!value.includes("\0")) id = value;
|
|
54
|
+
} else if (field === "retry" && /^\d+$/u.test(value)) retry = Number(value);
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done) break;
|
|
60
|
+
let text = decoder.decode(value, { stream: true });
|
|
61
|
+
if (skipLineFeed && text.startsWith("\n")) text = text.slice(1);
|
|
62
|
+
if (text !== "") skipLineFeed = false;
|
|
63
|
+
buffer += text;
|
|
64
|
+
let start = 0;
|
|
65
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
66
|
+
const code = buffer.charCodeAt(index);
|
|
67
|
+
if (code !== 10 && code !== 13) continue;
|
|
68
|
+
const line = buffer.slice(start, index);
|
|
69
|
+
if (code === 13) if (index + 1 < buffer.length) {
|
|
70
|
+
if (buffer.charCodeAt(index + 1) === 10) index += 1;
|
|
71
|
+
} else skipLineFeed = true;
|
|
72
|
+
start = index + 1;
|
|
73
|
+
const event = apply(line);
|
|
74
|
+
if (event !== void 0) yield event;
|
|
75
|
+
}
|
|
76
|
+
buffer = buffer.slice(start);
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
signal?.removeEventListener("abort", onAbort);
|
|
80
|
+
await reader.cancel().catch(() => void 0);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { readSSE };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//#region ../../plugins/acryl-kv/src/kv.d.ts
|
|
2
|
+
/** `T`, except a promise: the callback of `update` is synchronous, and an async one is a type error. */
|
|
3
|
+
type NotPromise<T> = T extends PromiseLike<unknown> ? never : T;
|
|
4
|
+
/** Anything JSON can hold. */
|
|
5
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
6
|
+
[key: string]: JsonValue;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Named JSON documents. Every call returns a promise, so a file, SQLite and Postgres answer alike.
|
|
10
|
+
*
|
|
11
|
+
* Keys are non-empty strings. Values are JSON: `undefined`, functions, symbols, bigints and non-finite numbers are refused with a
|
|
12
|
+
* `TypeError` before anything is written, and a value read back is a copy.
|
|
13
|
+
*
|
|
14
|
+
* There is no `close`: the code that opened a store closes it (see {@link KvOpened}), so a plugin cannot close the store another uses.
|
|
15
|
+
*/
|
|
16
|
+
interface Kv {
|
|
17
|
+
/** The value stored under the key, or `undefined`. */
|
|
18
|
+
get<T = JsonValue>(key: string): Promise<T | undefined>;
|
|
19
|
+
/** Store a value under the key, replacing any. Resolves once it is durable. */
|
|
20
|
+
set<T = JsonValue>(key: string, value: T): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Replace the value under the key by `change(current)`, atomically: `current` is `undefined` when the key is missing, `change` is
|
|
23
|
+
* synchronous (an `async` function is a type error, and a returned promise is rejected at run time: read what you need first, then update), and no other write to the key happens between reading it and writing the result. Resolves the new value. A `change` that
|
|
24
|
+
* throws, or returns something that is not JSON, leaves the value as it was and rejects with its error.
|
|
25
|
+
*/
|
|
26
|
+
update<T = JsonValue>(key: string, change: (current: T | undefined) => NotPromise<T>): Promise<T>;
|
|
27
|
+
/** Remove the key. A missing key is not an error. */
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
|
+
/** The keys that start with the prefix (all of them without one), sorted by code point (the order of their UTF-8 bytes, not of UTF-16 units). */
|
|
30
|
+
list(prefix?: string): Promise<string[]>;
|
|
31
|
+
}
|
|
32
|
+
/** An open store and the one function that closes it. */
|
|
33
|
+
interface KvOpened {
|
|
34
|
+
readonly kv: Kv;
|
|
35
|
+
/** Waits for the writes in flight, then releases the storage. Calling it again does nothing; calls on `kv` after it reject. */
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region ../../plugins/acryl-kv/src/check.d.ts
|
|
40
|
+
/**
|
|
41
|
+
* What every `kv` provider checks the same way, so a bad call fails alike on a file and on SQL: the key, the value, and the order of keys.
|
|
42
|
+
*/
|
|
43
|
+
/** A key is a non-empty string. */
|
|
44
|
+
declare function checkKey(key: unknown): asserts key is string;
|
|
45
|
+
/**
|
|
46
|
+
* The JSON text of a value. `undefined`, functions, symbols, bigints, circular structures and non-finite numbers (which JSON would turn
|
|
47
|
+
* into `null`) are refused with a `TypeError`; nothing is stored for them.
|
|
48
|
+
*/
|
|
49
|
+
declare function encodeValue(value: unknown): string;
|
|
50
|
+
/** The value of stored JSON text: a new copy each time, so changing what `get` returned changes nothing stored. */
|
|
51
|
+
declare function decodeValue<T>(text: string): T;
|
|
52
|
+
/** Order two keys by code point (the order of UTF-8 bytes, which SQLite's binary collation and Postgres' "C" collation give too). */
|
|
53
|
+
declare function compareKeys(a: string, b: string): number;
|
|
54
|
+
/** The number of code points in a string (what SQL's `substr` counts). */
|
|
55
|
+
declare function codePoints(text: string): number;
|
|
56
|
+
/**
|
|
57
|
+
* Call an `update` callback and check what it returns: the callback is synchronous, so a promise (an `async` function the types should have
|
|
58
|
+
* refused) is rejected with a message that says what to do, instead of being stored as `{}`.
|
|
59
|
+
*/
|
|
60
|
+
declare function runChange<T>(change: (current: T | undefined) => T, current: T | undefined): T;
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region ../../plugins/acryl-kv/src/copy.d.ts
|
|
63
|
+
interface CopyKvOptions {
|
|
64
|
+
/** Replace keys the target already has. Without it, a shared key stops the copy before anything is written. */
|
|
65
|
+
readonly overwrite?: boolean;
|
|
66
|
+
/** Copy only the keys that start with this. */
|
|
67
|
+
readonly prefix?: string;
|
|
68
|
+
}
|
|
69
|
+
interface CopyKvResult {
|
|
70
|
+
/** Keys written to the target. */
|
|
71
|
+
readonly copied: number;
|
|
72
|
+
/** Of those, keys the target already had. */
|
|
73
|
+
readonly overwritten: number;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Copy every key of one store into another: the import when an app moves from a JSON file to SQL (the file opened with
|
|
77
|
+
* `acryl-kv-json` is the source, `kvOnDb` the target). The source is not changed.
|
|
78
|
+
* @throws when the target has a key the source has and `overwrite` is not set; nothing is written then.
|
|
79
|
+
*/
|
|
80
|
+
declare function copyKv(source: Kv, target: Kv, options?: CopyKvOptions): Promise<CopyKvResult>;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region ../../plugins/acryl-kv/src/index.d.ts
|
|
83
|
+
/**
|
|
84
|
+
* The `kv` port: named JSON documents for an app too small for SQL, whichever provider serves it (`acryl-kv-json` for one file,
|
|
85
|
+
* `acryl-kv-db` over the `db` port). Plain code opens a store with a provider's `openKv`; a Cordis plugin declares `inject: ['kv']`, imports
|
|
86
|
+
* `acryl-kv/cordis` for the `ctx.kv` type, and never imports a provider.
|
|
87
|
+
*
|
|
88
|
+
* This entry imports nothing from Cordis, at runtime or in its types.
|
|
89
|
+
*/
|
|
90
|
+
/** The service name every `kv` provider registers. */
|
|
91
|
+
declare const KV_SERVICE = "kv";
|
|
92
|
+
//#endregion
|
|
93
|
+
export { checkKey as a, decodeValue as c, JsonValue as d, Kv as f, copyKv as i, encodeValue as l, NotPromise as m, CopyKvOptions as n, codePoints as o, KvOpened as p, CopyKvResult as r, compareKeys as s, KV_SERVICE as t, runChange as u };
|
package/lib/kv/db.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { s as Db } from "../index-D4FAsBZu.js";
|
|
2
|
+
import { f as Kv } from "../index-BLTYsyc1.js";
|
|
3
|
+
|
|
4
|
+
//#region ../../plugins/acryl-kv-db/src/open.d.ts
|
|
5
|
+
interface KvOnDbOptions {
|
|
6
|
+
/** The table that holds the documents. Defaults to `kv`; a plain SQL identifier. */
|
|
7
|
+
readonly table?: string;
|
|
8
|
+
}
|
|
9
|
+
/** The `kv` port over a `db`. It does not own the database: whoever opened it closes it, and calls after that reject. */
|
|
10
|
+
declare function kvOnDb(db: Db, options?: KvOnDbOptions): Kv;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { type Kv, KvOnDbOptions, kvOnDb };
|
package/lib/kv/db.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { a as compareKeys, c as runChange, i as codePoints, o as decodeValue, r as checkKey, s as encodeValue } from "../src-DRFVEQQb.js";
|
|
2
|
+
//#region ../../plugins/acryl-kv-db/src/open.ts
|
|
3
|
+
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/u;
|
|
4
|
+
/** The `kv` port over a `db`. It does not own the database: whoever opened it closes it, and calls after that reject. */
|
|
5
|
+
function kvOnDb(db, options = {}) {
|
|
6
|
+
const table = options.table ?? "kv";
|
|
7
|
+
if (!IDENTIFIER.test(table)) throw new TypeError(`acryl-kv-db: the table name must be a plain lower case SQL identifier, got ${JSON.stringify(table)}`);
|
|
8
|
+
let ready;
|
|
9
|
+
const ensure = () => {
|
|
10
|
+
ready ??= db.exec(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`).catch((error) => {
|
|
11
|
+
ready = void 0;
|
|
12
|
+
throw error;
|
|
13
|
+
});
|
|
14
|
+
return ready;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Run a statement after the table exists. The table's statement and this one are both handed to the database at once (its queue keeps
|
|
18
|
+
* their order), so a `close()` called right after a write cannot slip between them and leave the write on a closed database.
|
|
19
|
+
*/
|
|
20
|
+
const after = async (work) => {
|
|
21
|
+
const created = ensure();
|
|
22
|
+
const result = work();
|
|
23
|
+
const [, value] = await Promise.all([created, result]);
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
const upsert = `INSERT INTO ${table} (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`;
|
|
27
|
+
return {
|
|
28
|
+
async get(key) {
|
|
29
|
+
checkKey(key);
|
|
30
|
+
const row = await after(() => db.get(`SELECT value FROM ${table} WHERE key = ?`, key));
|
|
31
|
+
return row === void 0 ? void 0 : decodeValue(row.value);
|
|
32
|
+
},
|
|
33
|
+
async set(key, value) {
|
|
34
|
+
checkKey(key);
|
|
35
|
+
const text = encodeValue(value);
|
|
36
|
+
await after(() => db.run(upsert, key, text));
|
|
37
|
+
},
|
|
38
|
+
async update(key, change) {
|
|
39
|
+
checkKey(key);
|
|
40
|
+
return after(() => db.transaction(async (tx) => {
|
|
41
|
+
const row = await tx.get(`SELECT value FROM ${table} WHERE key = ?`, key);
|
|
42
|
+
const text = encodeValue(runChange(change, row === void 0 ? void 0 : decodeValue(row.value)));
|
|
43
|
+
await tx.run(upsert, key, text);
|
|
44
|
+
return decodeValue(text);
|
|
45
|
+
}));
|
|
46
|
+
},
|
|
47
|
+
async delete(key) {
|
|
48
|
+
checkKey(key);
|
|
49
|
+
await after(() => db.run(`DELETE FROM ${table} WHERE key = ?`, key));
|
|
50
|
+
},
|
|
51
|
+
async list(prefix = "") {
|
|
52
|
+
return (await after(() => prefix === "" ? db.all(`SELECT key FROM ${table}`) : db.all(`SELECT key FROM ${table} WHERE substr(key, 1, ?) = ?`, codePoints(prefix), prefix))).map((row) => row.key).sort(compareKeys);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { kvOnDb };
|
package/lib/kv/json.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { f as Kv, p as KvOpened } from "../index-BLTYsyc1.js";
|
|
2
|
+
|
|
3
|
+
//#region ../../plugins/acryl-kv-json/src/open.d.ts
|
|
4
|
+
interface OpenKvOptions {
|
|
5
|
+
/** The JSON file; its folder is created if missing. */
|
|
6
|
+
readonly path: string;
|
|
7
|
+
}
|
|
8
|
+
/** Open (and create, when missing) a JSON-file store. @throws when the file exists and is not a readable kv file. */
|
|
9
|
+
declare function openKv(options: OpenKvOptions): Promise<KvOpened>;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { type Kv, type KvOpened, OpenKvOptions, openKv };
|
package/lib/kv/json.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { a as compareKeys, c as runChange, o as decodeValue, r as checkKey, s as encodeValue } from "../src-DRFVEQQb.js";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
//#region ../../plugins/acryl-kv-json/src/open.ts
|
|
5
|
+
/**
|
|
6
|
+
* The plain way to open the JSON-file provider, for code without Cordis (an acrylblends app): `openKv({ path })` reads one JSON file into
|
|
7
|
+
* memory and resolves the `kv` port with the function that closes it. The Cordis plugin is a thin wrapper over this, so both run one code
|
|
8
|
+
* path. This entry imports nothing from Cordis.
|
|
9
|
+
*
|
|
10
|
+
* Every write persists the whole map: serialize to a temporary file in the same folder, flush it, rename it over the file, and flush the
|
|
11
|
+
* folder where the platform allows. The call resolves after the rename, so a crash leaves the old file or the new one, never half of one.
|
|
12
|
+
* Writes are grouped: the calls made in one turn of the event loop, and those that queue while a file write is in flight, are applied in
|
|
13
|
+
* call order and persisted together by one write, so a burst of calls costs one flush and one rename, not one each (on Windows each costs
|
|
14
|
+
* tens of milliseconds). One process owns the
|
|
15
|
+
* file: two writers would overwrite each other.
|
|
16
|
+
*/
|
|
17
|
+
/** The file's own format version, for a later change of layout. */
|
|
18
|
+
const FORMAT = 1;
|
|
19
|
+
/** Stray temporary files from a crash end in `.tmp` and start with the file's name. */
|
|
20
|
+
const tempPrefix = (file) => `${basename(file)}.`;
|
|
21
|
+
/** The file content for a map of key to JSON text, keys in code point order, so the same data is the same bytes. */
|
|
22
|
+
function serialize(entries) {
|
|
23
|
+
const body = [...entries.keys()].sort(compareKeys).map((key) => `${JSON.stringify(key)}:${entries.get(key)}`).join(",");
|
|
24
|
+
return `{"version":${FORMAT},"entries":{${body}}}\n`;
|
|
25
|
+
}
|
|
26
|
+
function unreadable(file, reason) {
|
|
27
|
+
return /* @__PURE__ */ new Error(`acryl-kv-json: ${file} is not a readable kv file (${reason}); it was not changed`);
|
|
28
|
+
}
|
|
29
|
+
/** The entries of a file, or none when it does not exist. A file that exists and does not parse is an error: it is never started over. */
|
|
30
|
+
async function load(file) {
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = await readFile(file, "utf8");
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === "ENOENT") return /* @__PURE__ */ new Map();
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(text);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw unreadable(file, error.message);
|
|
43
|
+
}
|
|
44
|
+
if (typeof parsed !== "object" || parsed === null || parsed.version !== FORMAT) throw unreadable(file, "not a version 1 kv file");
|
|
45
|
+
const entries = parsed.entries;
|
|
46
|
+
if (typeof entries !== "object" || entries === null || Array.isArray(entries)) throw unreadable(file, "no entries object");
|
|
47
|
+
return new Map(Object.entries(entries).map(([key, value]) => [key, JSON.stringify(value)]));
|
|
48
|
+
}
|
|
49
|
+
/** Remove the temporary files an earlier crash left beside the file. */
|
|
50
|
+
async function removeStrays(file) {
|
|
51
|
+
const prefix = tempPrefix(file);
|
|
52
|
+
for (const name of await readdir(dirname(file))) if (name.startsWith(prefix) && name.endsWith(".tmp")) await rm(join(dirname(file), name), { force: true });
|
|
53
|
+
}
|
|
54
|
+
const RENAME_RETRIES = 8;
|
|
55
|
+
/** Rename over the target. Windows refuses while a scanner or an indexer holds the target for a moment, so it is retried briefly. */
|
|
56
|
+
async function replace(from, to) {
|
|
57
|
+
for (let attempt = 0;; attempt += 1) try {
|
|
58
|
+
await rename(from, to);
|
|
59
|
+
return;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const code = error.code;
|
|
62
|
+
if (attempt >= RENAME_RETRIES || code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw error;
|
|
63
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 10 * (attempt + 1)));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Flush the folder entry of a renamed file (POSIX); Windows cannot open a folder, and a filesystem that refuses is no worse than before. */
|
|
67
|
+
async function flushFolder(folder) {
|
|
68
|
+
if (process.platform === "win32") return;
|
|
69
|
+
try {
|
|
70
|
+
const handle = await open(folder, "r");
|
|
71
|
+
try {
|
|
72
|
+
await handle.sync();
|
|
73
|
+
} finally {
|
|
74
|
+
await handle.close();
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
/** Open (and create, when missing) a JSON-file store. @throws when the file exists and is not a readable kv file. */
|
|
79
|
+
async function openKv(options) {
|
|
80
|
+
const file = resolve(options.path);
|
|
81
|
+
await mkdir(dirname(file), { recursive: true });
|
|
82
|
+
await removeStrays(file);
|
|
83
|
+
let state = await load(file);
|
|
84
|
+
let closed = false;
|
|
85
|
+
let sequence = 0;
|
|
86
|
+
/** Calls waiting for the next file write, in call order. */
|
|
87
|
+
let queue = [];
|
|
88
|
+
/** Whether the loop that writes the queue runs; cleared by the loop itself, so a call queued after it ends starts a new one. */
|
|
89
|
+
let draining = false;
|
|
90
|
+
/** The latest loop, for `close` to wait on. */
|
|
91
|
+
let drained = Promise.resolve();
|
|
92
|
+
const guard = () => {
|
|
93
|
+
if (closed) throw new Error(`acryl-kv-json: ${file} is closed`);
|
|
94
|
+
};
|
|
95
|
+
/** Persist a map: the file is the old one or this one, never between. */
|
|
96
|
+
const persist = async (next) => {
|
|
97
|
+
sequence += 1;
|
|
98
|
+
const temp = join(dirname(file), `${tempPrefix(file)}${process.pid}.${sequence}.tmp`);
|
|
99
|
+
try {
|
|
100
|
+
const handle = await open(temp, "w");
|
|
101
|
+
try {
|
|
102
|
+
await handle.writeFile(serialize(next), "utf8");
|
|
103
|
+
await handle.sync();
|
|
104
|
+
} finally {
|
|
105
|
+
await handle.close();
|
|
106
|
+
}
|
|
107
|
+
await replace(temp, file);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await rm(temp, { force: true });
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
await flushFolder(dirname(file));
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Write the queue until it is empty. Each round takes every call queued so far and applies them in order to one copy of the state. A call
|
|
116
|
+
* whose change throws rejects with its error and changes nothing, and the others in the round still apply. The copy becomes the state
|
|
117
|
+
* only once it is on disk. A failed file write rejects every call in its round and leaves the state as it was.
|
|
118
|
+
*/
|
|
119
|
+
const drain = async () => {
|
|
120
|
+
try {
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
await rounds();
|
|
123
|
+
} finally {
|
|
124
|
+
draining = false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const rounds = async () => {
|
|
128
|
+
while (queue.length > 0) {
|
|
129
|
+
const round = queue;
|
|
130
|
+
queue = [];
|
|
131
|
+
const draft = new Map(state);
|
|
132
|
+
const applied = [];
|
|
133
|
+
for (const call of round) {
|
|
134
|
+
let change;
|
|
135
|
+
try {
|
|
136
|
+
change = call.change((key) => draft.get(key));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
call.reject(error);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (change.text === void 0) draft.delete(change.key);
|
|
142
|
+
else draft.set(change.key, change.text);
|
|
143
|
+
applied.push({
|
|
144
|
+
call,
|
|
145
|
+
result: change.result
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (applied.length === 0) continue;
|
|
149
|
+
try {
|
|
150
|
+
await persist(draft);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
for (const { call } of applied) call.reject(error);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
state = draft;
|
|
156
|
+
for (const { call, result } of applied) call.resolve(result);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
/** Queue one change behind every earlier call; it resolves once a file write that contains it is on disk. */
|
|
160
|
+
const write = (change) => {
|
|
161
|
+
guard();
|
|
162
|
+
const done = new Promise((resolveCall, rejectCall) => {
|
|
163
|
+
queue.push({
|
|
164
|
+
change,
|
|
165
|
+
resolve: resolveCall,
|
|
166
|
+
reject: rejectCall
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
if (!draining) {
|
|
170
|
+
draining = true;
|
|
171
|
+
drained = drain();
|
|
172
|
+
}
|
|
173
|
+
return done;
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
kv: {
|
|
177
|
+
async get(key) {
|
|
178
|
+
guard();
|
|
179
|
+
checkKey(key);
|
|
180
|
+
const text = state.get(key);
|
|
181
|
+
return text === void 0 ? void 0 : decodeValue(text);
|
|
182
|
+
},
|
|
183
|
+
async set(key, value) {
|
|
184
|
+
checkKey(key);
|
|
185
|
+
const text = encodeValue(value);
|
|
186
|
+
await write(() => ({
|
|
187
|
+
key,
|
|
188
|
+
text,
|
|
189
|
+
result: void 0
|
|
190
|
+
}));
|
|
191
|
+
},
|
|
192
|
+
async update(key, change) {
|
|
193
|
+
checkKey(key);
|
|
194
|
+
return write((read) => {
|
|
195
|
+
const text = read(key);
|
|
196
|
+
const encoded = encodeValue(runChange(change, text === void 0 ? void 0 : decodeValue(text)));
|
|
197
|
+
return {
|
|
198
|
+
key,
|
|
199
|
+
text: encoded,
|
|
200
|
+
result: decodeValue(encoded)
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
async delete(key) {
|
|
205
|
+
checkKey(key);
|
|
206
|
+
await write(() => ({
|
|
207
|
+
key,
|
|
208
|
+
text: void 0,
|
|
209
|
+
result: void 0
|
|
210
|
+
}));
|
|
211
|
+
},
|
|
212
|
+
async list(prefix = "") {
|
|
213
|
+
guard();
|
|
214
|
+
return [...state.keys()].filter((key) => key.startsWith(prefix)).sort(compareKeys);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
close: async () => {
|
|
218
|
+
closed = true;
|
|
219
|
+
await drained;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
export { openKv };
|
package/lib/kv.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as checkKey, c as decodeValue, d as JsonValue, f as Kv, i as copyKv, l as encodeValue, m as NotPromise, n as CopyKvOptions, o as codePoints, p as KvOpened, r as CopyKvResult, s as compareKeys, t as KV_SERVICE, u as runChange } from "./index-BLTYsyc1.js";
|
|
2
|
+
export { type CopyKvOptions, type CopyKvResult, type JsonValue, KV_SERVICE, type Kv, type KvOpened, type NotPromise, checkKey, codePoints, compareKeys, copyKv, decodeValue, encodeValue, runChange };
|
package/lib/kv.js
ADDED
package/lib/node-server.d.ts
CHANGED
|
@@ -136,7 +136,7 @@ interface ClientModulesOptions {
|
|
|
136
136
|
readonly compiled?: string | undefined;
|
|
137
137
|
/** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
|
|
138
138
|
readonly cacheBytes?: number | undefined;
|
|
139
|
-
/** The folder holding `jsx-dom.js` and `
|
|
139
|
+
/** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
|
|
140
140
|
readonly runtimeDir?: string | undefined;
|
|
141
141
|
}
|
|
142
142
|
/** Options of {@link buildClientModules}. */
|
|
@@ -145,7 +145,7 @@ interface BuildClientModulesOptions {
|
|
|
145
145
|
readonly root: string;
|
|
146
146
|
/** Where the compiled modules go, at the same relative paths and file names; it must not lie inside `root`. */
|
|
147
147
|
readonly outDir: string;
|
|
148
|
-
/** The folder holding `jsx-dom.js` and `
|
|
148
|
+
/** The folder holding `jsx-dom.js`, `client.js` and `read-sse.js`; the framework's own when not given. */
|
|
149
149
|
readonly runtimeDir?: string | undefined;
|
|
150
150
|
}
|
|
151
151
|
/**
|
|
@@ -159,7 +159,7 @@ interface BuildClientModulesOptions {
|
|
|
159
159
|
declare function clientModules(options: ClientModulesOptions): Acryl;
|
|
160
160
|
/**
|
|
161
161
|
* Compile a folder of client modules once, for a production that has no `typescript`: each module is written to `outDir` at the same
|
|
162
|
-
* relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the
|
|
162
|
+
* relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the three
|
|
163
163
|
* browser files are copied to `outDir/@acrylblends/`. Files already in `outDir` that no source produces are left there.
|
|
164
164
|
* @param options - the source folder and the output folder.
|
|
165
165
|
* @returns the paths written, relative to `outDir`.
|
package/lib/node-server.js
CHANGED
|
@@ -1330,14 +1330,14 @@ function serve(options, listeningListener) {
|
|
|
1330
1330
|
/**
|
|
1331
1331
|
* Client components without a bundler. `clientModules({ root: 'src/client' })` is an app that serves the TypeScript and JSX modules of a
|
|
1332
1332
|
* folder to the browser as ES modules: a `.tsx`/`.ts`/`.jsx` file compiled with the app's own `typescript` (JSX for `acrylblends/jsx/dom`),
|
|
1333
|
-
* a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`,
|
|
1334
|
-
* `acrylblends/client`) are rewritten to
|
|
1333
|
+
* a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`,
|
|
1334
|
+
* `acrylblends/client` and `acrylblends/read-sse`) are rewritten to three self-contained files the same app serves, at a URL relative to each module, so a page needs
|
|
1335
1335
|
* one `<script type="module" src="/client/counter.tsx">` and no import map, under whatever path the app is routed at.
|
|
1336
1336
|
*
|
|
1337
1337
|
* Production runs without `typescript` (a deploy that leaves out devDependencies): `buildClientModules` writes the same compiled modules
|
|
1338
|
-
* and the
|
|
1338
|
+
* and the three browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
|
|
1339
1339
|
*/
|
|
1340
|
-
/** The folder the
|
|
1340
|
+
/** The folder the three browser files are served from, below the app's mount. A source folder of this name is not served. */
|
|
1341
1341
|
const RUNTIME_DIR = "@acrylblends";
|
|
1342
1342
|
/** The framework's browser imports and the browser file that provides each. */
|
|
1343
1343
|
const BROWSER_IMPORTS = {
|
|
@@ -1345,9 +1345,14 @@ const BROWSER_IMPORTS = {
|
|
|
1345
1345
|
"acrylblends/jsx/dom/jsx-runtime": "jsx-dom.js",
|
|
1346
1346
|
"acrylblends/jsx/dom/jsx-dev-runtime": "jsx-dom.js",
|
|
1347
1347
|
"acrylblends/jsx/dom/client": "jsx-dom.js",
|
|
1348
|
-
"acrylblends/client": "client.js"
|
|
1348
|
+
"acrylblends/client": "client.js",
|
|
1349
|
+
"acrylblends/read-sse": "read-sse.js"
|
|
1349
1350
|
};
|
|
1350
|
-
const RUNTIME_FILES = [
|
|
1351
|
+
const RUNTIME_FILES = [
|
|
1352
|
+
"jsx-dom.js",
|
|
1353
|
+
"client.js",
|
|
1354
|
+
"read-sse.js"
|
|
1355
|
+
];
|
|
1351
1356
|
const COMPILED = /* @__PURE__ */ new Set([
|
|
1352
1357
|
".ts",
|
|
1353
1358
|
".tsx",
|
|
@@ -1362,7 +1367,7 @@ const SERVED = /* @__PURE__ */ new Set([
|
|
|
1362
1367
|
const JS_TYPE = "text/javascript; charset=utf-8";
|
|
1363
1368
|
/** The cache bound when `cacheBytes` is not given. */
|
|
1364
1369
|
const DEFAULT_CACHE_BYTES = 16 * 1024 * 1024;
|
|
1365
|
-
/** The framework's
|
|
1370
|
+
/** The framework's three browser files, next to the built module that contains this code. */
|
|
1366
1371
|
const defaultRuntimeDir = () => fileURLToPath(new URL("./browser/", import.meta.url));
|
|
1367
1372
|
/** A module path below the root (`widgets/counter.tsx`) with the URL prefix that reaches the browser files from it. */
|
|
1368
1373
|
const runtimePrefix = (relativePath) => {
|
|
@@ -1551,7 +1556,7 @@ function sourceFiles(root, dir = root) {
|
|
|
1551
1556
|
}
|
|
1552
1557
|
/**
|
|
1553
1558
|
* Compile a folder of client modules once, for a production that has no `typescript`: each module is written to `outDir` at the same
|
|
1554
|
-
* relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the
|
|
1559
|
+
* relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the three
|
|
1555
1560
|
* browser files are copied to `outDir/@acrylblends/`. Files already in `outDir` that no source produces are left there.
|
|
1556
1561
|
* @param options - the source folder and the output folder.
|
|
1557
1562
|
* @returns the paths written, relative to `outDir`.
|