@myphp-plugins/watch 0.0.0-stage → 1.28.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.
Files changed (3) hide show
  1. package/index.ts +195 -0
  2. package/package.json +19 -4
  3. package/README.md +0 -3
package/index.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The watch plugin of MyPHP (mygo's @mygo-plugins/watch): changes to the
3
+ * files and directories the app lets pages watch, by the names it gives
4
+ * them. The PHP side must use the plugin with those directories:
5
+ *
6
+ * ```php
7
+ * App::use(Watch::new(new Options(roots: [ // MyPHP\Plugins\Watch
8
+ * 'project' => new Root($dir, recursive: true),
9
+ * ])));
10
+ * ```
11
+ *
12
+ * ```ts
13
+ * import { watch } from "@myphp-plugins/watch";
14
+ *
15
+ * const watcher = await watch("project", { recursive: true });
16
+ * for await (const events of watcher) {
17
+ * for (const event of events) console.log(event.op, event.path);
18
+ * }
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ import { Channel, call } from "myphp-runtime";
24
+
25
+ /** The kind of a {@link FileEvent}. */
26
+ export type FileOp = "create" | "write" | "remove" | "rename";
27
+
28
+ /**
29
+ * A change in a watched directory or file. Events say what is different
30
+ * once changes stop, not every step that made it so: treat them as hints
31
+ * to read the files again.
32
+ */
33
+ export interface FileEvent {
34
+ op: FileOp;
35
+ /** The path relative to what is watched, separated by slashes; "." is what is watched itself. */
36
+ path: string;
37
+ /** Where a rename moved from. */
38
+ oldPath?: string;
39
+ isDir: boolean;
40
+ }
41
+
42
+ /** Options of {@link watch}. */
43
+ export interface WatchOptions {
44
+ /** What to watch in the root, a directory or a file, relative to it and separated by slashes: the root itself by default. */
45
+ path?: string;
46
+ /** Watch the directories in the directory too, if the app allows it. */
47
+ recursive?: boolean;
48
+ /** How long changes must stop before they are reported: 50 ms when 0 or omitted, right away when negative. */
49
+ debounceMs?: number;
50
+ /** Stops the watch when aborted. */
51
+ signal?: AbortSignal;
52
+ }
53
+
54
+ /** Why a watch failed or stopped; see {@link WatchError}. */
55
+ export type WatchErrorCode = "denied" | "limit" | "not-found" | "unsupported" | "path-encoding" | "io";
56
+
57
+ /** A watch that failed to start or stopped, with the reason as its code. */
58
+ export class WatchError extends Error {
59
+ constructor(
60
+ readonly code: WatchErrorCode,
61
+ message: string,
62
+ ) {
63
+ super(message);
64
+ this.name = "WatchError";
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A running watch: iterate it for the changes, a batch of events each time
70
+ * they stop. PHP doesn't look again until the page has taken a batch, so
71
+ * nothing is lost while the page is busy.
72
+ */
73
+ export interface Watcher extends AsyncIterable<FileEvent[]> {
74
+ /** Settles once PHP has stopped the watch, rejecting with the error that stopped it. */
75
+ readonly closed: Promise<void>;
76
+ /** Stops the watch; it resolves once PHP has. */
77
+ close(): Promise<void>;
78
+ }
79
+
80
+ /** What PHP streams. */
81
+ interface Part {
82
+ type: "ready" | "events";
83
+ events?: FileEvent[];
84
+ }
85
+
86
+ /** How the watch ended, the result of PHP's call: the code of what failed, if anything. */
87
+ interface Outcome {
88
+ code?: string;
89
+ message?: string;
90
+ }
91
+
92
+ const codes: readonly string[] = ["denied", "limit", "not-found", "unsupported", "path-encoding", "io"];
93
+
94
+ /**
95
+ * Watches the directory or file the app named `root`, or `path` in it, and
96
+ * resolves once everything is watched. What is already there is not
97
+ * reported. When it is removed, renamed or replaced, the last batch has a
98
+ * "remove" of ".", and the iteration ends.
99
+ */
100
+ export async function watch(root: string, options: WatchOptions = {}): Promise<Watcher> {
101
+ const debounceMs = options.debounceMs ?? 0;
102
+ if (!Number.isInteger(debounceMs) || debounceMs > 60000) {
103
+ throw new TypeError("debounceMs must be an integer up to 60000");
104
+ }
105
+ const path = options.path ?? ".";
106
+ if (typeof path !== "string") throw new TypeError("path must be a string");
107
+ if (options.signal?.aborted) throw options.signal.reason;
108
+ const parts = new Channel<Part>();
109
+ const stream = parts[Symbol.asyncIterator]();
110
+ // The first error wins: the signal's reason, or the call's.
111
+ let terminal: { error: unknown } | undefined;
112
+ let closing = false;
113
+ let finished = false;
114
+ let iterating = false;
115
+ const record = (error: unknown) => {
116
+ terminal ??= { error };
117
+ };
118
+ const abort = () => {
119
+ if (finished || closing) return;
120
+ record(options.signal!.reason);
121
+ closing = true;
122
+ parts.close();
123
+ };
124
+ options.signal?.addEventListener("abort", abort, { once: true });
125
+ const closed = call<Outcome | null>("plugin:watch.watch", { root, path, recursive: options.recursive ?? false, debounceMs }, parts)
126
+ .then(
127
+ (outcome) => {
128
+ if (outcome?.code) {
129
+ record(
130
+ codes.includes(outcome.code)
131
+ ? new WatchError(outcome.code as WatchErrorCode, outcome.message ?? "filesystem watch failed")
132
+ : new Error("Invalid watch outcome"),
133
+ );
134
+ }
135
+ if (terminal) throw terminal.error;
136
+ },
137
+ (err: unknown) => {
138
+ record(err);
139
+ throw terminal!.error;
140
+ },
141
+ )
142
+ .finally(() => {
143
+ finished = true;
144
+ options.signal?.removeEventListener("abort", abort);
145
+ });
146
+ void closed.catch(() => {});
147
+ const close = async () => {
148
+ if (!closing && !finished) {
149
+ closing = true;
150
+ parts.close();
151
+ }
152
+ await closed.catch(() => {});
153
+ };
154
+ try {
155
+ const first = await stream.next();
156
+ if (first.done) {
157
+ await closed;
158
+ throw new Error("Watch ended before ready");
159
+ }
160
+ if (first.value.type !== "ready") {
161
+ parts.close();
162
+ await closed;
163
+ throw new Error("Invalid watch ready response");
164
+ }
165
+ if (terminal) {
166
+ await closed;
167
+ throw terminal.error;
168
+ }
169
+ } catch (err) {
170
+ parts.close();
171
+ options.signal?.removeEventListener("abort", abort);
172
+ throw err;
173
+ }
174
+ return {
175
+ closed,
176
+ close,
177
+ [Symbol.asyncIterator]() {
178
+ if (iterating) throw new Error("A watch supports one iterator");
179
+ iterating = true;
180
+ return (async function* () {
181
+ try {
182
+ while (!closing) {
183
+ const next = await stream.next();
184
+ if (next.done) break;
185
+ if (next.value.events?.length && !closing) yield next.value.events;
186
+ }
187
+ await closed;
188
+ } finally {
189
+ await close();
190
+ iterating = false;
191
+ }
192
+ })();
193
+ },
194
+ };
195
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@myphp-plugins/watch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.28.0",
4
+ "description": "The page side of MyPHP's watch plugin: changes to the files in the directories the app lets its pages watch, over myphp-runtime",
5
+ "type": "module",
6
+ "exports": "./index.ts",
7
+ "types": "./index.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/owner888/myphp.git",
11
+ "directory": "src/Plugins/Watch/client"
12
+ },
13
+ "license": "MIT",
14
+ "files": ["index.ts"],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "peerDependencies": {
19
+ "myphp-runtime": "^1.28.0"
20
+ }
21
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.