@oh-my-pi/pi-natives 8.12.4

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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Worker script for running wasm-bindgen grep.
3
+ * Each worker loads its own WASM instance and processes requests.
4
+ */
5
+
6
+ import * as fs from "node:fs/promises";
7
+ import * as path from "node:path";
8
+ import { globPaths } from "@oh-my-pi/pi-utils";
9
+ import { CompiledPattern } from "../../wasm/pi_natives";
10
+ import { buildGlobPattern, matchesTypeFilter, resolveTypeFilter } from "./filters";
11
+ import type { GrepMatch, GrepOptions, GrepResult, WasmSearchResult, WorkerRequest, WorkerResponse } from "./types";
12
+
13
+ function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
14
+ const result = {} as T;
15
+ for (const [key, value] of Object.entries(obj)) {
16
+ if (value !== undefined) {
17
+ (result as Record<string, unknown>)[key] = value;
18
+ }
19
+ }
20
+ return result;
21
+ }
22
+
23
+ async function runGrep(request: GrepOptions): Promise<GrepResult> {
24
+ const searchPath = path.resolve(request.path);
25
+ const stat = await fs.stat(searchPath);
26
+ const isFile = stat.isFile();
27
+
28
+ using compiledPattern = new CompiledPattern(
29
+ filterUndefined({
30
+ pattern: request.pattern,
31
+ ignoreCase: request.ignoreCase,
32
+ multiline: request.multiline,
33
+ context: request.context,
34
+ maxColumns: request.maxColumns,
35
+ mode: request.mode === "content" || !request.mode ? "content" : "count",
36
+ }),
37
+ );
38
+
39
+ const matches: GrepMatch[] = [];
40
+ let totalMatches = 0;
41
+ let filesWithMatches = 0;
42
+ let filesSearched = 0;
43
+ let limitReached = false;
44
+ const maxCount = request.maxCount;
45
+ const globalOffset = request.offset ?? 0;
46
+ const typeFilter = resolveTypeFilter(request.type);
47
+ const globPattern = buildGlobPattern(request.glob);
48
+
49
+ if (isFile) {
50
+ if (typeFilter && !matchesTypeFilter(searchPath, typeFilter)) {
51
+ return {
52
+ matches,
53
+ totalMatches,
54
+ filesWithMatches,
55
+ filesSearched,
56
+ limitReached: limitReached || undefined,
57
+ };
58
+ }
59
+
60
+ const content = Bun.mmap(searchPath);
61
+ filesSearched = 1;
62
+
63
+ const result = compiledPattern.search_bytes(
64
+ content,
65
+ maxCount,
66
+ globalOffset > 0 ? globalOffset : undefined,
67
+ ) as WasmSearchResult;
68
+
69
+ if (!result.error && result.matchCount > 0) {
70
+ filesWithMatches = 1;
71
+ totalMatches = result.matchCount;
72
+
73
+ if (request.mode === "content" || !request.mode) {
74
+ for (const m of result.matches) {
75
+ matches.push({
76
+ path: searchPath,
77
+ lineNumber: m.lineNumber,
78
+ line: m.line,
79
+ contextBefore: m.contextBefore?.length ? m.contextBefore : undefined,
80
+ contextAfter: m.contextAfter?.length ? m.contextAfter : undefined,
81
+ truncated: m.truncated || undefined,
82
+ });
83
+ }
84
+ } else {
85
+ matches.push({
86
+ path: searchPath,
87
+ lineNumber: 0,
88
+ line: "",
89
+ matchCount: result.matchCount,
90
+ });
91
+ }
92
+
93
+ limitReached = result.limitReached || (maxCount !== undefined && totalMatches >= maxCount);
94
+ }
95
+ } else {
96
+ const paths = await globPaths(globPattern, {
97
+ cwd: searchPath,
98
+ dot: request.hidden ?? true,
99
+ onlyFiles: true,
100
+ gitignore: true,
101
+ });
102
+
103
+ for (const relativePath of paths) {
104
+ if (limitReached) break;
105
+ if (typeFilter && !matchesTypeFilter(relativePath, typeFilter)) {
106
+ continue;
107
+ }
108
+
109
+ const normalizedPath = relativePath.replace(/\\/g, "/");
110
+ const fullPath = path.join(searchPath, normalizedPath);
111
+
112
+ let content: Uint8Array;
113
+ try {
114
+ content = Bun.mmap(fullPath);
115
+ } catch {
116
+ continue;
117
+ }
118
+
119
+ filesSearched++;
120
+
121
+ if (!compiledPattern.has_match_bytes(content)) {
122
+ continue;
123
+ }
124
+
125
+ const fileOffset = globalOffset > 0 ? Math.max(globalOffset - totalMatches, 0) : 0;
126
+ const remaining = maxCount !== undefined ? Math.max(maxCount - totalMatches, 0) : undefined;
127
+ if (remaining === 0) {
128
+ limitReached = true;
129
+ break;
130
+ }
131
+ const result = compiledPattern.search_bytes(
132
+ content,
133
+ remaining,
134
+ fileOffset > 0 ? fileOffset : undefined,
135
+ ) as WasmSearchResult;
136
+
137
+ if (result.error) continue;
138
+
139
+ if (result.matchCount > 0) {
140
+ filesWithMatches++;
141
+ totalMatches += result.matchCount;
142
+
143
+ if (request.mode === "content" || !request.mode) {
144
+ for (const m of result.matches) {
145
+ matches.push({
146
+ path: normalizedPath,
147
+ lineNumber: m.lineNumber,
148
+ line: m.line,
149
+ contextBefore: m.contextBefore?.length ? m.contextBefore : undefined,
150
+ contextAfter: m.contextAfter?.length ? m.contextAfter : undefined,
151
+ truncated: m.truncated || undefined,
152
+ });
153
+ }
154
+ } else {
155
+ matches.push({
156
+ path: normalizedPath,
157
+ lineNumber: 0,
158
+ line: "",
159
+ matchCount: result.matchCount,
160
+ });
161
+ }
162
+
163
+ if (result.limitReached || (maxCount !== undefined && totalMatches >= maxCount)) {
164
+ limitReached = true;
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ return {
171
+ matches,
172
+ totalMatches,
173
+ filesWithMatches,
174
+ filesSearched,
175
+ limitReached: limitReached || undefined,
176
+ };
177
+ }
178
+
179
+ declare const self: Worker;
180
+
181
+ self.addEventListener("message", async (e: MessageEvent<WorkerRequest>) => {
182
+ const msg = e.data;
183
+
184
+ switch (msg.type) {
185
+ case "init":
186
+ self.postMessage({ type: "ready", id: msg.id } satisfies WorkerResponse);
187
+ break;
188
+
189
+ case "grep":
190
+ try {
191
+ const result = await runGrep(msg.request);
192
+ self.postMessage({ type: "result", id: msg.id, result } satisfies WorkerResponse);
193
+ } catch (err) {
194
+ self.postMessage({
195
+ type: "error",
196
+ id: msg.id,
197
+ error: err instanceof Error ? err.message : String(err),
198
+ } satisfies WorkerResponse);
199
+ }
200
+ break;
201
+
202
+ case "destroy":
203
+ break;
204
+ }
205
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Async image processing via worker.
3
+ *
4
+ * All heavy lifting happens in a worker thread to avoid blocking the main thread.
5
+ * Uses transferable ArrayBuffers to avoid copying image data.
6
+ */
7
+
8
+ import { WorkerPool } from "../pool";
9
+ import type { ImageRequest, ImageResponse } from "./types";
10
+
11
+ // Re-export the enum for filter selection
12
+ export { SamplingFilter } from "../../wasm/pi_natives";
13
+
14
+ const pool = new WorkerPool<ImageRequest, ImageResponse>({
15
+ workerUrl: new URL("./worker.ts", import.meta.url).href,
16
+ maxWorkers: 1,
17
+ idleTimeoutMs: 0, // Keep alive - stateful (image handles)
18
+ });
19
+
20
+ /**
21
+ * Image handle for async operations.
22
+ * Must call free() when done to release WASM memory.
23
+ */
24
+ export class PhotonImage {
25
+ #handle: number;
26
+ #width: number;
27
+ #height: number;
28
+ #freed = false;
29
+
30
+ private constructor(handle: number, width: number, height: number) {
31
+ this.#handle = handle;
32
+ this.#width = width;
33
+ this.#height = height;
34
+ }
35
+
36
+ /** @internal */
37
+ static _create(handle: number, width: number, height: number): PhotonImage {
38
+ return new PhotonImage(handle, width, height);
39
+ }
40
+
41
+ /** @internal */
42
+ _getHandle(): number {
43
+ if (this.#freed) throw new Error("Image already freed");
44
+ return this.#handle;
45
+ }
46
+
47
+ /**
48
+ * Load an image from encoded bytes (PNG, JPEG, WebP, GIF).
49
+ * The bytes are transferred to the worker (zero-copy).
50
+ */
51
+ static async new_from_byteslice(bytes: Uint8Array): Promise<PhotonImage> {
52
+ const response = await pool.request<Extract<ImageResponse, { type: "loaded" }>>({ type: "load", bytes }, [
53
+ bytes.buffer,
54
+ ]);
55
+ return new PhotonImage(response.handle, response.width, response.height);
56
+ }
57
+
58
+ /** Get image width in pixels. */
59
+ get_width(): number {
60
+ return this.#width;
61
+ }
62
+
63
+ /** Get image height in pixels. */
64
+ get_height(): number {
65
+ return this.#height;
66
+ }
67
+
68
+ /** Export as PNG bytes. */
69
+ async get_bytes(): Promise<Uint8Array> {
70
+ if (this.#freed) throw new Error("Image already freed");
71
+ const response = await pool.request<Extract<ImageResponse, { type: "bytes" }>>({
72
+ type: "get_png",
73
+ handle: this.#handle,
74
+ });
75
+ return response.bytes;
76
+ }
77
+
78
+ /** Export as JPEG bytes with specified quality (0-100). */
79
+ async get_bytes_jpeg(quality: number): Promise<Uint8Array> {
80
+ if (this.#freed) throw new Error("Image already freed");
81
+ const response = await pool.request<Extract<ImageResponse, { type: "bytes" }>>({
82
+ type: "get_jpeg",
83
+ handle: this.#handle,
84
+ quality,
85
+ });
86
+ return response.bytes;
87
+ }
88
+
89
+ /** Release WASM memory. Must be called when done with the image. */
90
+ free() {
91
+ if (this.#freed) return;
92
+ this.#freed = true;
93
+ pool.request({ type: "free", handle: this.#handle }).catch(() => {});
94
+ }
95
+
96
+ /** Alias for free() to support using-declarations. */
97
+ [Symbol.dispose](): void {
98
+ this.free();
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Resize an image to the specified dimensions.
104
+ * Returns a new PhotonImage (original is not modified).
105
+ */
106
+ export async function resize(image: PhotonImage, width: number, height: number, filter: number): Promise<PhotonImage> {
107
+ const handle = image._getHandle();
108
+ const response = await pool.request<Extract<ImageResponse, { type: "resized" }>>({
109
+ type: "resize",
110
+ handle,
111
+ width,
112
+ height,
113
+ filter,
114
+ });
115
+ return PhotonImage._create(response.handle, response.width, response.height);
116
+ }
117
+
118
+ /**
119
+ * Terminate the image worker.
120
+ * Call this when shutting down to clean up resources.
121
+ */
122
+ export function terminate(): void {
123
+ pool.terminate();
124
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Types for image worker communication.
3
+ */
4
+
5
+ export type ImageRequest =
6
+ | { type: "init"; id: number }
7
+ | { type: "destroy" }
8
+ | {
9
+ type: "load";
10
+ id: number;
11
+ /** Image bytes (transferred, not copied) */
12
+ bytes: Uint8Array;
13
+ }
14
+ | {
15
+ type: "resize";
16
+ id: number;
17
+ /** Handle returned from load */
18
+ handle: number;
19
+ width: number;
20
+ height: number;
21
+ filter: number;
22
+ }
23
+ | {
24
+ type: "get_dimensions";
25
+ id: number;
26
+ handle: number;
27
+ }
28
+ | {
29
+ type: "get_png";
30
+ id: number;
31
+ handle: number;
32
+ }
33
+ | {
34
+ type: "get_jpeg";
35
+ id: number;
36
+ handle: number;
37
+ quality: number;
38
+ }
39
+ | {
40
+ type: "free";
41
+ id: number;
42
+ handle: number;
43
+ };
44
+
45
+ export type ImageResponse =
46
+ | { type: "ready"; id: number }
47
+ | { type: "error"; id: number; error: string }
48
+ | { type: "loaded"; id: number; handle: number; width: number; height: number }
49
+ | { type: "resized"; id: number; handle: number; width: number; height: number }
50
+ | { type: "dimensions"; id: number; width: number; height: number }
51
+ | { type: "bytes"; id: number; bytes: Uint8Array }
52
+ | { type: "freed"; id: number };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Worker for image processing operations.
3
+ * Uses WASM for actual processing, communicates via transferable buffers.
4
+ */
5
+
6
+ import { PhotonImage, type SamplingFilter, resize as wasmResize } from "../../wasm/pi_natives";
7
+ import type { ImageRequest, ImageResponse } from "./types";
8
+
9
+ declare const self: Worker;
10
+
11
+ /** Map of handle -> PhotonImage */
12
+ const images = new Map<number, PhotonImage>();
13
+ let nextHandle = 1;
14
+
15
+ function respond(msg: ImageResponse, transfer?: ArrayBufferLike[]): void {
16
+ if (transfer) {
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ self.postMessage(msg, transfer as any);
19
+ } else {
20
+ self.postMessage(msg);
21
+ }
22
+ }
23
+
24
+ self.addEventListener("message", (e: MessageEvent<ImageRequest>) => {
25
+ const msg = e.data;
26
+
27
+ switch (msg.type) {
28
+ case "init":
29
+ respond({ type: "ready", id: msg.id });
30
+ break;
31
+
32
+ case "destroy":
33
+ for (const img of images.values()) {
34
+ img.free();
35
+ }
36
+ images.clear();
37
+ break;
38
+
39
+ case "load": {
40
+ try {
41
+ const img = PhotonImage.new_from_byteslice(msg.bytes);
42
+ const handle = nextHandle++;
43
+ images.set(handle, img);
44
+ respond({
45
+ type: "loaded",
46
+ id: msg.id,
47
+ handle,
48
+ width: img.get_width(),
49
+ height: img.get_height(),
50
+ });
51
+ } catch (err) {
52
+ respond({
53
+ type: "error",
54
+ id: msg.id,
55
+ error: err instanceof Error ? err.message : String(err),
56
+ });
57
+ }
58
+ break;
59
+ }
60
+
61
+ case "resize": {
62
+ const img = images.get(msg.handle);
63
+ if (!img) {
64
+ respond({ type: "error", id: msg.id, error: "Invalid image handle" });
65
+ break;
66
+ }
67
+ try {
68
+ const filter = msg.filter as SamplingFilter;
69
+ const resized = wasmResize(img, msg.width, msg.height, filter);
70
+ const handle = nextHandle++;
71
+ images.set(handle, resized);
72
+ respond({
73
+ type: "resized",
74
+ id: msg.id,
75
+ handle,
76
+ width: resized.get_width(),
77
+ height: resized.get_height(),
78
+ });
79
+ } catch (err) {
80
+ respond({
81
+ type: "error",
82
+ id: msg.id,
83
+ error: err instanceof Error ? err.message : String(err),
84
+ });
85
+ }
86
+ break;
87
+ }
88
+
89
+ case "get_dimensions": {
90
+ const img = images.get(msg.handle);
91
+ if (!img) {
92
+ respond({ type: "error", id: msg.id, error: "Invalid image handle" });
93
+ break;
94
+ }
95
+ respond({
96
+ type: "dimensions",
97
+ id: msg.id,
98
+ width: img.get_width(),
99
+ height: img.get_height(),
100
+ });
101
+ break;
102
+ }
103
+
104
+ case "get_png": {
105
+ const img = images.get(msg.handle);
106
+ if (!img) {
107
+ respond({ type: "error", id: msg.id, error: "Invalid image handle" });
108
+ break;
109
+ }
110
+ try {
111
+ const bytes = img.get_bytes();
112
+ respond({ type: "bytes", id: msg.id, bytes }, [bytes.buffer]);
113
+ } catch (err) {
114
+ respond({
115
+ type: "error",
116
+ id: msg.id,
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ }
120
+ break;
121
+ }
122
+
123
+ case "get_jpeg": {
124
+ const img = images.get(msg.handle);
125
+ if (!img) {
126
+ respond({ type: "error", id: msg.id, error: "Invalid image handle" });
127
+ break;
128
+ }
129
+ try {
130
+ const bytes = img.get_bytes_jpeg(msg.quality);
131
+ respond({ type: "bytes", id: msg.id, bytes }, [bytes.buffer]);
132
+ } catch (err) {
133
+ respond({
134
+ type: "error",
135
+ id: msg.id,
136
+ error: err instanceof Error ? err.message : String(err),
137
+ });
138
+ }
139
+ break;
140
+ }
141
+
142
+ case "free": {
143
+ const img = images.get(msg.handle);
144
+ if (img) {
145
+ img.free();
146
+ images.delete(msg.handle);
147
+ }
148
+ respond({ type: "freed", id: msg.id });
149
+ break;
150
+ }
151
+ }
152
+ });
package/src/index.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Native utilities powered by WASM.
3
+ */
4
+
5
+ import * as fs from "node:fs/promises";
6
+ import * as path from "node:path";
7
+ import { globPaths } from "@oh-my-pi/pi-utils";
8
+
9
+ // =============================================================================
10
+ // Grep (ripgrep-based regex search)
11
+ // =============================================================================
12
+
13
+ export {
14
+ type ContextLine,
15
+ type GrepMatch,
16
+ type GrepOptions,
17
+ type GrepResult,
18
+ type GrepSummary,
19
+ grep,
20
+ grepDirect,
21
+ grepPool,
22
+ hasMatch,
23
+ searchContent,
24
+ terminate,
25
+ } from "./grep/index";
26
+
27
+ // =============================================================================
28
+ // WASI implementation
29
+ // =============================================================================
30
+
31
+ export { WASI1, WASIError, WASIExitError, type WASIOptions } from "./wasix";
32
+
33
+ // =============================================================================
34
+ // Find (file discovery)
35
+ // =============================================================================
36
+
37
+ export interface FindOptions {
38
+ /** Glob pattern to match (e.g., `*.ts`) */
39
+ pattern: string;
40
+ /** Directory to search */
41
+ path: string;
42
+ /** Filter by file type: "file", "dir", or "symlink" */
43
+ fileType?: "file" | "dir" | "symlink";
44
+ /** Include hidden files (default: false) */
45
+ hidden?: boolean;
46
+ /** Maximum number of results */
47
+ maxResults?: number;
48
+ /** Respect .gitignore files (default: true) */
49
+ gitignore?: boolean;
50
+ }
51
+
52
+ export interface FindMatch {
53
+ path: string;
54
+ fileType: "file" | "dir" | "symlink";
55
+ }
56
+
57
+ export interface FindResult {
58
+ matches: FindMatch[];
59
+ totalMatches: number;
60
+ }
61
+
62
+ /**
63
+ * Find files matching a glob pattern.
64
+ * Respects .gitignore by default.
65
+ */
66
+ export async function find(options: FindOptions, onMatch?: (match: FindMatch) => void): Promise<FindResult> {
67
+ const searchPath = path.resolve(options.path);
68
+ const pattern = options.pattern || "*";
69
+
70
+ // Convert simple patterns to recursive globs if needed
71
+ const globPattern = pattern.includes("/") || pattern.startsWith("**") ? pattern : `**/${pattern}`;
72
+
73
+ const paths = await globPaths(globPattern, {
74
+ cwd: searchPath,
75
+ dot: options.hidden ?? false,
76
+ onlyFiles: options.fileType === "file",
77
+ gitignore: options.gitignore ?? true,
78
+ });
79
+
80
+ const matches: FindMatch[] = [];
81
+ const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER;
82
+
83
+ for (const p of paths) {
84
+ if (matches.length >= maxResults) {
85
+ break;
86
+ }
87
+
88
+ const normalizedPath = p.replace(/\\/g, "/");
89
+ if (!normalizedPath) {
90
+ continue;
91
+ }
92
+
93
+ let stats: Awaited<ReturnType<typeof fs.lstat>>;
94
+ try {
95
+ stats = await fs.lstat(path.join(searchPath, normalizedPath));
96
+ } catch {
97
+ continue;
98
+ }
99
+
100
+ const fileType: "file" | "dir" | "symlink" = stats.isSymbolicLink()
101
+ ? "symlink"
102
+ : stats.isDirectory()
103
+ ? "dir"
104
+ : "file";
105
+
106
+ if (options.fileType && options.fileType !== fileType) {
107
+ continue;
108
+ }
109
+
110
+ const match: FindMatch = {
111
+ path: normalizedPath,
112
+ fileType,
113
+ };
114
+
115
+ matches.push(match);
116
+ onMatch?.(match);
117
+ }
118
+
119
+ return {
120
+ matches,
121
+ totalMatches: matches.length,
122
+ };
123
+ }
124
+
125
+ // =============================================================================
126
+ // Image processing (photon-compatible API)
127
+ // =============================================================================
128
+
129
+ export {
130
+ PhotonImage,
131
+ resize,
132
+ SamplingFilter,
133
+ terminate as terminateImageWorker,
134
+ } from "./image/index";
135
+
136
+ // =============================================================================
137
+ // Worker Pool (shared infrastructure)
138
+ // =============================================================================
139
+
140
+ export { type BaseRequest, type BaseResponse, WorkerPool, type WorkerPoolOptions } from "./pool";