···11-module(goose_ffi).
22--export([receive_ws_message/0]).
22+-export([priv_dir/0, decompress_using_ddict_safe/2, decompress_streaming_safe/2]).
3344-%% Receive a WebSocket text message from the process mailbox
55-receive_ws_message() ->
66- receive
77- %% Handle messages forwarded from handler process
88- {ws_text, Text} ->
99- {ok, Text};
1010- {ws_binary, _Binary} ->
1111- %% Ignore binary messages, try again
1212- receive_ws_message();
1313- {ws_closed, _Reason} ->
1414- {error, closed};
1515- {ws_error, _Reason} ->
1616- {error, connection_error};
1717- _Other ->
1818- %% Ignore unexpected messages
1919- receive_ws_message()
2020- after 60000 ->
2121- %% Timeout - connection is still alive, just no messages
2222- {error, timeout}
44+%% Get the priv directory for the goose application
55+priv_dir() ->
66+ case code:priv_dir(goose) of
77+ {error, _} -> "./priv";
88+ Path when is_list(Path) -> list_to_binary(Path);
99+ Path -> Path
1010+ end.
1111+1212+%% Safe wrapper for ezstd:decompress_using_ddict that always returns Result type
1313+decompress_using_ddict_safe(Data, DDict) ->
1414+ case ezstd:decompress_using_ddict(Data, DDict) of
1515+ Result when is_binary(Result) ->
1616+ {ok, Result};
1717+ Result when is_list(Result) ->
1818+ {ok, iolist_to_binary(Result)};
1919+ {error, Err} when is_binary(Err) ->
2020+ %% Keep error as binary (Gleam String)
2121+ {error, Err};
2222+ {error, Err} when is_list(Err) ->
2323+ %% Convert list to binary
2424+ {error, list_to_binary(Err)};
2525+ {error, Err} ->
2626+ %% Convert any other type to binary
2727+ {error, list_to_binary(lists:flatten(io_lib:format("~p", [Err])))}
2828+ end.
2929+3030+%% Safe wrapper for ezstd:decompress_streaming that always returns Result type
3131+decompress_streaming_safe(DCtx, Data) ->
3232+ case ezstd:decompress_streaming(DCtx, Data) of
3333+ Result when is_binary(Result) ->
3434+ {ok, Result};
3535+ Result when is_list(Result) ->
3636+ {ok, iolist_to_binary(Result)};
3737+ {error, Err} when is_binary(Err) ->
3838+ %% Keep error as binary (Gleam String)
3939+ {error, Err};
4040+ {error, Err} when is_list(Err) ->
4141+ %% Convert list to binary
4242+ {error, list_to_binary(Err)};
4343+ {error, Err} ->
4444+ %% Convert any other type to binary
4545+ {error, list_to_binary(lists:flatten(io_lib:format("~p", [Err])))}
2346 end.
-216
src/goose_ws_ffi.erl
···11--module(goose_ws_ffi).
22--export([connect/3]).
33-44-%% Connect to WebSocket using gun
55-connect(Url, HandlerPid, Compress) ->
66- %% Start gun application and dependencies
77- application:ensure_all_started(ssl),
88- application:ensure_all_started(gun),
99-1010- %% Start ezstd if compression is enabled
1111- case Compress of
1212- true -> application:ensure_all_started(ezstd);
1313- _ -> ok
1414- end,
1515-1616- %% Spawn a connection process that will own the gun connection
1717- Parent = self(),
1818- spawn(fun() -> connect_worker(Url, HandlerPid, Compress, Parent) end),
1919-2020- %% Wait for the connection result
2121- receive
2222- {connection_result, Result} -> Result
2323- after 60000 ->
2424- {error, connection_timeout}
2525- end.
2626-2727-%% Worker process that owns the connection
2828-connect_worker(Url, HandlerPid, Compress, Parent) ->
2929- %% Parse URL using uri_string
3030- UriMap = uri_string:parse(Url),
3131- #{scheme := SchemeStr, host := Host, path := Path} = UriMap,
3232-3333- %% Get query string if present and append to path
3434- Query = maps:get(query, UriMap, undefined),
3535- PathWithQuery = case Query of
3636- undefined -> Path;
3737- <<>> -> Path;
3838- Q -> <<Path/binary, "?", Q/binary>>
3939- end,
4040-4141- %% Get port, use defaults if not specified
4242- Port = maps:get(port, uri_string:parse(Url),
4343- case SchemeStr of
4444- <<"wss">> -> 443;
4545- <<"ws">> -> 80;
4646- _ -> 443
4747- end),
4848-4949- %% Determine transport
5050- Transport = case SchemeStr of
5151- <<"wss">> -> tls;
5252- <<"ws">> -> tcp;
5353- _ -> tls
5454- end,
5555-5656- %% TLS options for secure connections
5757- TlsOpts = [{verify, verify_none}], %% For simplicity, disable cert verification
5858- %% In production, use proper CA certs
5959-6060- %% Connection options
6161- Opts = case Transport of
6262- tls ->
6363- #{
6464- transport => tls,
6565- tls_opts => TlsOpts,
6666- protocols => [http],
6767- retry => 10,
6868- retry_timeout => 1000
6969- };
7070- tcp ->
7171- #{
7272- transport => tcp,
7373- protocols => [http],
7474- retry => 10,
7575- retry_timeout => 1000
7676- }
7777- end,
7878-7979- %% Convert host to list if needed
8080- HostStr = case is_binary(Host) of
8181- true -> binary_to_list(Host);
8282- false -> Host
8383- end,
8484-8585- %% Ensure path with query is binary
8686- PathBin = case is_binary(PathWithQuery) of
8787- true -> PathWithQuery;
8888- false -> list_to_binary(PathWithQuery)
8989- end,
9090-9191- %% Open connection (this process will be the owner)
9292- case gun:open(HostStr, Port, Opts) of
9393- {ok, ConnPid} ->
9494- %% Monitor the connection
9595- MRef = monitor(process, ConnPid),
9696-9797- %% Wait for connection
9898- receive
9999- {gun_up, ConnPid, _Protocol} ->
100100- %% Upgrade to WebSocket (compression is controlled via query string, not headers)
101101- StreamRef = gun:ws_upgrade(ConnPid, binary_to_list(PathBin), []),
102102-103103- %% Wait for upgrade
104104- receive
105105- {gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _ResponseHeaders} ->
106106- %% Notify parent that connection is ready
107107- Parent ! {connection_result, {ok, ConnPid}},
108108- %% Now handle messages in this process (the connection owner)
109109- handle_messages(ConnPid, StreamRef, HandlerPid, Compress);
110110- {gun_response, ConnPid, _, _, Status, Headers} ->
111111- gun:close(ConnPid),
112112- Parent ! {connection_result, {error, {upgrade_failed, Status, Headers}}};
113113- {gun_error, ConnPid, _StreamRef, Reason} ->
114114- gun:close(ConnPid),
115115- Parent ! {connection_result, {error, {gun_error, Reason}}};
116116- {'DOWN', MRef, process, ConnPid, Reason} ->
117117- Parent ! {connection_result, {error, {connection_down, Reason}}};
118118- _Other ->
119119- gun:close(ConnPid),
120120- Parent ! {connection_result, {error, unexpected_message}}
121121- after 30000 ->
122122- gun:close(ConnPid),
123123- Parent ! {connection_result, {error, upgrade_timeout}}
124124- end;
125125- {'DOWN', MRef, process, ConnPid, Reason} ->
126126- Parent ! {connection_result, {error, {connection_failed, Reason}}};
127127- _Other ->
128128- gun:close(ConnPid),
129129- Parent ! {connection_result, {error, unexpected_message}}
130130- after 30000 ->
131131- gun:close(ConnPid),
132132- Parent ! {connection_result, {error, connection_timeout}}
133133- end;
134134- {error, Reason} ->
135135- Parent ! {connection_result, {error, {open_failed, Reason}}}
136136- end.
137137-138138-%% Handle incoming WebSocket messages
139139-handle_messages(ConnPid, StreamRef, HandlerPid, Compress) ->
140140- %% Load zstd dictionary if compression is enabled
141141- Decompressor = case Compress of
142142- true ->
143143- %% Load dictionary from priv directory
144144- PrivDir = code:priv_dir(goose),
145145- DictPath = filename:join(PrivDir, "zstd_dictionary"),
146146- case file:read_file(DictPath) of
147147- {ok, DictData} ->
148148- %% Create decompression context and dictionary
149149- DCtx = ezstd:create_decompression_context(1024 * 1024),
150150- DDict = ezstd:create_ddict(DictData),
151151- %% Select the dictionary for the decompression context
152152- ok = ezstd:select_ddict(DCtx, DDict),
153153- {ok, {DCtx, DDict}};
154154- {error, Err} ->
155155- io:format("Failed to load zstd dictionary: ~p~n", [Err]),
156156- {error, Err}
157157- end;
158158- _ ->
159159- none
160160- end,
161161- handle_messages_loop(ConnPid, StreamRef, HandlerPid, Compress, Decompressor).
162162-163163-%% Message handling loop
164164-handle_messages_loop(ConnPid, StreamRef, HandlerPid, Compress, Decompressor) ->
165165- receive
166166- {gun_ws, _AnyConnPid, _AnyStreamRef, {text, Text}} ->
167167- HandlerPid ! {ws_text, Text},
168168- handle_messages_loop(ConnPid, StreamRef, HandlerPid, Compress, Decompressor);
169169- {gun_ws, _AnyConnPid, _AnyStreamRef, {binary, Binary}} ->
170170- %% If compression is enabled, decompress the binary data
171171- case {Compress, Decompressor} of
172172- {true, {ok, {DCtx, DDict}}} ->
173173- %% Try decompress_using_ddict first (works for frames with content size)
174174- case ezstd:decompress_using_ddict(Binary, DDict) of
175175- Result when is_binary(Result) ->
176176- HandlerPid ! {ws_text, Result};
177177- Result when is_list(Result) ->
178178- HandlerPid ! {ws_text, iolist_to_binary(Result)};
179179- {error, <<"failed to decompress: ZSTD_CONTENTSIZE_UNKNOWN">>} ->
180180- %% Frame doesn't have content size, use streaming with dictionary-loaded context
181181- case ezstd:decompress_streaming(DCtx, Binary) of
182182- StreamResult when is_binary(StreamResult) ->
183183- HandlerPid ! {ws_text, StreamResult};
184184- StreamResult when is_list(StreamResult) ->
185185- HandlerPid ! {ws_text, iolist_to_binary(StreamResult)};
186186- {error, _StreamReason} ->
187187- %% Skip frames that fail to decompress
188188- ok
189189- end;
190190- {error, _Reason} ->
191191- %% Skip frames that fail to decompress
192192- ok
193193- end;
194194- _ ->
195195- %% No compression, ignore binary messages
196196- ok
197197- end,
198198- handle_messages_loop(ConnPid, StreamRef, HandlerPid, Compress, Decompressor);
199199- {gun_ws, ConnPid, StreamRef, close} ->
200200- HandlerPid ! {ws_closed, normal},
201201- gun:close(ConnPid);
202202- {gun_down, ConnPid, _Protocol, Reason, _KilledStreams} ->
203203- HandlerPid ! {ws_error, Reason},
204204- gun:close(ConnPid);
205205- {gun_error, ConnPid, StreamRef, Reason} ->
206206- HandlerPid ! {ws_error, Reason},
207207- handle_messages_loop(ConnPid, StreamRef, HandlerPid, Compress, Decompressor);
208208- stop ->
209209- gun:close(ConnPid);
210210- _Other ->
211211- %% Ignore unexpected messages
212212- handle_messages_loop(ConnPid, StreamRef, HandlerPid, Compress, Decompressor)
213213- after 30000 ->
214214- %% Heartbeat every 30 seconds to keep connection alive
215215- handle_messages_loop(ConnPid, StreamRef, HandlerPid, Compress, Decompressor)
216216- end.