@heyputer/puter.js 2.0.1 → 2.0.3

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 (68) hide show
  1. package/dist/puter.js +4 -0
  2. package/package.json +4 -4
  3. package/src/bg.png +0 -0
  4. package/src/bg.webp +0 -0
  5. package/src/index.js +165 -165
  6. package/src/lib/APICallLogger.js +110 -0
  7. package/src/lib/EventListener.js +51 -0
  8. package/src/lib/RequestError.js +6 -0
  9. package/src/lib/filesystem/APIFS.js +73 -0
  10. package/src/lib/filesystem/CacheFS.js +243 -0
  11. package/src/lib/filesystem/PostMessageFS.js +40 -0
  12. package/src/lib/filesystem/definitions.js +39 -0
  13. package/src/lib/path.js +509 -0
  14. package/src/lib/polyfills/localStorage.js +92 -0
  15. package/src/lib/polyfills/xhrshim.js +233 -0
  16. package/src/lib/socket.io/socket.io.esm.min.js +7 -0
  17. package/src/lib/socket.io/socket.io.esm.min.js.map +1 -0
  18. package/src/lib/socket.io/socket.io.js +4385 -0
  19. package/src/lib/socket.io/socket.io.js.map +1 -0
  20. package/src/lib/socket.io/socket.io.min.js +7 -0
  21. package/src/lib/socket.io/socket.io.min.js.map +1 -0
  22. package/src/lib/socket.io/socket.io.msgpack.min.js +7 -0
  23. package/src/lib/socket.io/socket.io.msgpack.min.js.map +1 -0
  24. package/src/lib/utils.js +620 -0
  25. package/src/lib/xdrpc.js +104 -0
  26. package/src/modules/AI.js +680 -0
  27. package/src/modules/Apps.js +215 -0
  28. package/src/modules/Auth.js +171 -0
  29. package/src/modules/Debug.js +39 -0
  30. package/src/modules/Drivers.js +278 -0
  31. package/src/modules/FSItem.js +139 -0
  32. package/src/modules/FileSystem/index.js +187 -0
  33. package/src/modules/FileSystem/operations/copy.js +64 -0
  34. package/src/modules/FileSystem/operations/deleteFSEntry.js +59 -0
  35. package/src/modules/FileSystem/operations/getReadUrl.js +42 -0
  36. package/src/modules/FileSystem/operations/mkdir.js +62 -0
  37. package/src/modules/FileSystem/operations/move.js +75 -0
  38. package/src/modules/FileSystem/operations/read.js +46 -0
  39. package/src/modules/FileSystem/operations/readdir.js +102 -0
  40. package/src/modules/FileSystem/operations/rename.js +58 -0
  41. package/src/modules/FileSystem/operations/sign.js +103 -0
  42. package/src/modules/FileSystem/operations/space.js +40 -0
  43. package/src/modules/FileSystem/operations/stat.js +95 -0
  44. package/src/modules/FileSystem/operations/symlink.js +55 -0
  45. package/src/modules/FileSystem/operations/upload.js +440 -0
  46. package/src/modules/FileSystem/operations/write.js +65 -0
  47. package/src/modules/FileSystem/utils/getAbsolutePathForApp.js +21 -0
  48. package/src/modules/Hosting.js +138 -0
  49. package/src/modules/KV.js +301 -0
  50. package/src/modules/OS.js +95 -0
  51. package/src/modules/Perms.js +109 -0
  52. package/src/modules/PuterDialog.js +481 -0
  53. package/src/modules/Threads.js +75 -0
  54. package/src/modules/UI.js +1555 -0
  55. package/src/modules/Util.js +38 -0
  56. package/src/modules/Workers.js +120 -0
  57. package/src/modules/networking/PSocket.js +87 -0
  58. package/src/modules/networking/PTLS.js +100 -0
  59. package/src/modules/networking/PWispHandler.js +89 -0
  60. package/src/modules/networking/parsers.js +157 -0
  61. package/src/modules/networking/requests.js +282 -0
  62. package/src/safeLoadPuter.cjs +29 -0
  63. package/src/services/APIAccess.js +46 -0
  64. package/src/services/FSRelay.js +20 -0
  65. package/src/services/Filesystem.js +122 -0
  66. package/src/services/NoPuterYet.js +20 -0
  67. package/src/services/XDIncoming.js +44 -0
  68. package/index.d.ts +0 -479
@@ -0,0 +1,282 @@
1
+ // SO: https://stackoverflow.com/a/76332760/ under CC BY-SA 4.0
2
+ function mergeUint8Arrays(...arrays) {
3
+ const totalSize = arrays.reduce((acc, e) => acc + e.length, 0);
4
+ const merged = new Uint8Array(totalSize);
5
+
6
+ arrays.forEach((array, i, arrays) => {
7
+ const offset = arrays.slice(0, i).reduce((acc, e) => acc + e.length, 0);
8
+ merged.set(array, offset);
9
+ });
10
+
11
+ return merged;
12
+ }
13
+
14
+ function parseHTTPHead(head) {
15
+ const lines = head.split("\r\n");
16
+
17
+ const firstLine = lines.shift().split(" ");
18
+ const status = Number(firstLine[1]);
19
+ const statusText = firstLine.slice(2).join(" ") || "";
20
+
21
+ const headersArray = [];
22
+ for (const header of lines) {
23
+ const splitHeaders = header.split(": ");
24
+ const key = splitHeaders[0];
25
+ const value = splitHeaders.slice(1).join(": ");
26
+ headersArray.push([key, value]);
27
+ }
28
+ new Headers(headersArray);
29
+ return { headers: new Headers(headersArray), statusText, status };
30
+ }
31
+
32
+ // Trivial stream based HTTP 1.1 client
33
+ // TODO optional redirect handling
34
+
35
+ export function pFetch(...args) {
36
+ return new Promise(async (res, rej) => {
37
+ try {
38
+ const reqObj = new Request(...args);
39
+ const parsedURL = new URL(reqObj.url);
40
+ let headers = new Headers(reqObj.headers); // Make a headers object we can modify
41
+
42
+ // Socket creation: regular for HTTP, TLS for https
43
+ let socket;
44
+ if (parsedURL.protocol === "http:") {
45
+ socket = new puter.net.Socket(
46
+ parsedURL.hostname,
47
+ parsedURL.port || 80,
48
+ );
49
+ } else if (parsedURL.protocol === "https:") {
50
+ socket = new puter.net.tls.TLSSocket(
51
+ parsedURL.hostname,
52
+ parsedURL.port || 443,
53
+ );
54
+ } else {
55
+ const errorMsg = `Failed to fetch. URL scheme "${parsedURL.protocol}" is not supported.`;
56
+
57
+ // Log the error
58
+ if (globalThis.puter?.apiCallLogger?.isEnabled()) {
59
+ globalThis.puter.apiCallLogger.logRequest({
60
+ service: 'network',
61
+ operation: 'pFetch',
62
+ params: { url: reqObj.url, method: reqObj.method },
63
+ error: { message: errorMsg }
64
+ });
65
+ }
66
+
67
+ rej(errorMsg);
68
+ return;
69
+ }
70
+
71
+ // Sending default UA
72
+ if (!headers.get("user-agent")) {
73
+ headers.set("user-agent", navigator.userAgent);
74
+ }
75
+
76
+ let reqHead = `${reqObj.method} ${parsedURL.pathname}${parsedURL.search} HTTP/1.1\r\nHost: ${parsedURL.host}\r\nConnection: close\r\n`;
77
+ for (const [key, value] of headers) {
78
+ reqHead += `${key}: ${value}\r\n`;
79
+ }
80
+ let requestBody;
81
+ if (reqObj.body) {
82
+ requestBody = new Uint8Array(await reqObj.arrayBuffer());
83
+ // If we have a body, we need to set the content length
84
+ if (!headers.has("content-length")) {
85
+ headers.set("content-length", requestBody.length);
86
+ } else if (
87
+ headers.get("content-length") !== String(requestBody.length)
88
+ ) {
89
+ return rej(
90
+ "Content-Length header does not match the body length. Please check your request.",
91
+ );
92
+ }
93
+ reqHead += `Content-Length: ${requestBody.length}\r\n`;
94
+ }
95
+
96
+ reqHead += "\r\n";
97
+
98
+ socket.on("open", async () => {
99
+ socket.write(reqHead); // Send headers
100
+ if (requestBody) {
101
+ socket.write(requestBody); // Send body if present
102
+ }
103
+ });
104
+ const decoder = new TextDecoder();
105
+ let responseHead = "";
106
+ let dataOffset = -1;
107
+ const fullDataParts = [];
108
+ let responseReturned = false;
109
+ let contentLength = -1;
110
+ let ingestedContent = 0;
111
+ let chunkedTransfer = false;
112
+ let currentChunkLeft = -1;
113
+ let buffer = new Uint8Array(0);
114
+
115
+ const outStream = new ReadableStream({
116
+ start(controller) {
117
+ // This is annoyingly long
118
+ function parseIncomingChunk(data) {
119
+ // append new data to our rolling buffer
120
+ const tmp = new Uint8Array(buffer.length + data.length);
121
+ tmp.set(buffer, 0);
122
+ tmp.set(data, buffer.length);
123
+ buffer = tmp;
124
+
125
+ // pull out as many complete chunks (or headers) as we can
126
+ while (true) {
127
+ if (currentChunkLeft > 0) {
128
+ // we’re in the middle of reading a chunk body
129
+ // need size + 2 bytes (for trailing \r\n)
130
+ if (buffer.length >= currentChunkLeft + 2) {
131
+ // full body + CRLF available
132
+ const chunk = buffer.slice(0, currentChunkLeft);
133
+ controller.enqueue(chunk);
134
+
135
+ // strip body + CRLF and reset for next header
136
+ buffer = buffer.slice(currentChunkLeft + 2);
137
+ currentChunkLeft = 0;
138
+ } else {
139
+ // only a partial body available
140
+ controller.enqueue(buffer);
141
+ currentChunkLeft -= buffer.length;
142
+ buffer = new Uint8Array(0);
143
+ break; // wait for more data
144
+ }
145
+ } else {
146
+ // we need to parse the next size line
147
+ // find the first "\r\n"
148
+ let idx = -1;
149
+ for (let i = 0; i + 1 < buffer.length; i++) {
150
+ if (
151
+ buffer[i] === 0x0d &&
152
+ buffer[i + 1] === 0x0a
153
+ ) {
154
+ idx = i;
155
+ break;
156
+ }
157
+ }
158
+ if (idx < 0) {
159
+ // we don’t yet have a full size line
160
+ break;
161
+ }
162
+
163
+ // decode just the size line as ASCII hex
164
+ const sizeText = decoder
165
+ .decode(buffer.slice(0, idx))
166
+ .trim();
167
+ currentChunkLeft = parseInt(sizeText, 16);
168
+ if (isNaN(currentChunkLeft)) {
169
+ controller.error(
170
+ "Invalid chunk length from server",
171
+ );
172
+ }
173
+ // strip off the size line + CRLF
174
+ buffer = buffer.slice(idx + 2);
175
+
176
+ // zero-length => end of stream
177
+ if (currentChunkLeft === 0) {
178
+ responseReturned = true;
179
+ controller.close();
180
+ return;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ socket.on("data", (data) => {
186
+ // Dataoffset is set to another value once head is returned, its safe to assume all remaining data is body
187
+ if (dataOffset !== -1 && !chunkedTransfer) {
188
+ controller.enqueue(data);
189
+ ingestedContent += data.length;
190
+ }
191
+
192
+ // We dont have the full responseHead yet
193
+ if (dataOffset === -1) {
194
+ fullDataParts.push(data);
195
+ responseHead += decoder.decode(data, { stream: true });
196
+ }
197
+ if (chunkedTransfer) {
198
+ parseIncomingChunk(data);
199
+ }
200
+
201
+ // See if we have the HEAD of an HTTP/1.1 yet
202
+ if (responseHead.indexOf("\r\n\r\n") !== -1) {
203
+ dataOffset = responseHead.indexOf("\r\n\r\n");
204
+ responseHead = responseHead.slice(0, dataOffset);
205
+ const parsedHead = parseHTTPHead(responseHead);
206
+ contentLength = Number(
207
+ parsedHead.headers.get("content-length"),
208
+ );
209
+ chunkedTransfer =
210
+ parsedHead.headers.get("transfer-encoding") ===
211
+ "chunked";
212
+
213
+ // Log the response
214
+ if (globalThis.puter?.apiCallLogger?.isEnabled()) {
215
+ globalThis.puter.apiCallLogger.logRequest({
216
+ service: 'network',
217
+ operation: 'pFetch',
218
+ params: { url: reqObj.url, method: reqObj.method },
219
+ result: { status: parsedHead.status, statusText: parsedHead.statusText }
220
+ });
221
+ }
222
+
223
+ // Return initial response object
224
+ res(new Response(outStream, parsedHead));
225
+
226
+ const residualBody = mergeUint8Arrays(
227
+ ...fullDataParts,
228
+ ).slice(dataOffset + 4);
229
+ if (!chunkedTransfer) {
230
+ // Add any content we have but isn't part of the head into the body stream
231
+ ingestedContent += residualBody.length;
232
+ controller.enqueue(residualBody);
233
+ } else {
234
+ parseIncomingChunk(residualBody);
235
+ }
236
+ }
237
+
238
+ if (
239
+ contentLength !== -1 &&
240
+ ingestedContent === contentLength &&
241
+ !chunkedTransfer
242
+ ) {
243
+ // Work around for the close bug for compliant HTTP/1.1 servers
244
+ if (!responseReturned) {
245
+ responseReturned = true;
246
+ controller.close();
247
+ }
248
+ }
249
+ });
250
+ socket.on("close", () => {
251
+ if (!responseReturned) {
252
+ responseReturned = true;
253
+ controller.close();
254
+ }
255
+ });
256
+ socket.on("error", (reason) => {
257
+ // Log the error
258
+ if (globalThis.puter?.apiCallLogger?.isEnabled()) {
259
+ globalThis.puter.apiCallLogger.logRequest({
260
+ service: 'network',
261
+ operation: 'pFetch',
262
+ params: { url: reqObj.url, method: reqObj.method },
263
+ error: { message: "Socket errored with the following reason: " + reason }
264
+ });
265
+ }
266
+ rej("Socket errored with the following reason: " + reason);
267
+ });
268
+ },
269
+ });
270
+ } catch (e) {
271
+ // Log unexpected errors
272
+ if (globalThis.puter?.apiCallLogger?.isEnabled()) {
273
+ globalThis.puter.apiCallLogger.logRequest({
274
+ service: 'network',
275
+ operation: 'pFetch',
276
+ params: { url: reqObj.url, method: reqObj.method },
277
+ error: { message: e.message || e.toString(), stack: e.stack }
278
+ });
279
+ }
280
+ rej(e);
281
+ }});
282
+ }
@@ -0,0 +1,29 @@
1
+ const readFileSync = require('node:fs');
2
+ const vm = require('node:vm');
3
+
4
+ /**
5
+ * Method for loading puter.js in Node.js environment
6
+ * @param {string} authToken - Optional auth token to initialize puter with
7
+ * @returns {Promise<import('../index').Puter>} The `puter` object from puter.js
8
+ */
9
+ const safeLoadPuterJs = (authToken) => {
10
+
11
+ const goodContext = {};
12
+ Object.getOwnPropertyNames(globalThis).forEach(name => {
13
+ try {
14
+ goodContext[name] = globalThis[name];
15
+ } catch {
16
+ // silent fail
17
+ }
18
+ });
19
+ goodContext.globalThis = goodContext;
20
+ const code = readFileSync(`${globalThis.__dirname}/../dist/puter.js`, 'utf8');
21
+ const context = vm.createContext(goodContext);
22
+ vm.runInNewContext(code, context);
23
+ if ( authToken ) {
24
+ goodContext.puter.setAuthToken(authToken);
25
+ }
26
+ return goodContext.puter;
27
+ };
28
+
29
+ module.exports = { safeLoadPuterJs };
@@ -0,0 +1,46 @@
1
+ import putility from "@heyputer/putility";
2
+
3
+ const { TTopics } = putility.traits;
4
+
5
+ /**
6
+ * Manages the auth token and origin used to communicate with
7
+ * Puter's API
8
+ */
9
+ export class APIAccessService extends putility.concepts.Service {
10
+ static TOPICS = ['update'];
11
+
12
+ static PROPERTIES = {
13
+ auth_token: {
14
+ post_set (v) {
15
+ this.as(TTopics).pub('update');
16
+ }
17
+ },
18
+ api_origin: {
19
+ post_set () {
20
+ this.as(TTopics).pub('update');
21
+ }
22
+ },
23
+ };
24
+
25
+ // TODO: inconsistent! Update all dependents.
26
+ get_api_info () {
27
+ const self = this;
28
+ const o = {};
29
+ [
30
+ ['auth_token','auth_token'],
31
+ ['authToken','auth_token'],
32
+ ['APIOrigin','api_origin'],
33
+ ['api_origin','api_origin'],
34
+ ].forEach(([k1,k2]) => {
35
+ Object.defineProperty(o, k1, {
36
+ get () {
37
+ return self[k2];
38
+ },
39
+ set (v) {
40
+ return self;
41
+ }
42
+ });
43
+ });
44
+ return o;
45
+ }
46
+ }
@@ -0,0 +1,20 @@
1
+ import putility from '@heyputer/putility';
2
+
3
+ const example = {
4
+ "id": "f485f1ba-de07-422c-8c4b-c2da057d4a44",
5
+ "uid": "f485f1ba-de07-422c-8c4b-c2da057d4a44",
6
+ "is_dir": true,
7
+ "immutable": true,
8
+ "name": "FromParentWindow",
9
+ };
10
+
11
+ export class FSRelayService extends putility.concepts.Service {
12
+ async _init () {
13
+ const services = this._.context.services;
14
+ const util = this._.context.util;
15
+ const svc_xdIncoming = services.get('xd-incoming');
16
+ svc_xdIncoming.register_tagged_listener('puter-fs', event => {
17
+ util.rpc.send(event.source, event.data.$callback, [example]);
18
+ });
19
+ }
20
+ }
@@ -0,0 +1,122 @@
1
+ import putility from "@heyputer/putility";
2
+ import { PuterAPIFilesystem } from "../lib/filesystem/APIFS.js";
3
+ import { CachedFilesystem } from "../lib/filesystem/CacheFS.js";
4
+ import { ProxyFilesystem, TFilesystem } from "../lib/filesystem/definitions.js";
5
+ import io from '../lib/socket.io/socket.io.esm.min.js';
6
+ import { PostMessageFilesystem } from "../lib/filesystem/PostMessageFS.js";
7
+
8
+ export class FilesystemService extends putility.concepts.Service {
9
+ static PROPERTIES = {
10
+ // filesystem:
11
+ };
12
+
13
+ static DEPENDS = ['api-access'];
14
+ static HOOKS = [
15
+ {
16
+ service: 'api-access',
17
+ event: 'update',
18
+ description: `
19
+ re-initialize the socket connection whenever the
20
+ authentication token or API origin is changed.
21
+ `,
22
+ async do () {
23
+ this.initializeSocket();
24
+ }
25
+ }
26
+ ]
27
+
28
+ _init () {
29
+ const env = this._.context.env;
30
+
31
+ if ( env === 'app' ) {
32
+ // TODO: uncomment when relay is ready
33
+ // this.init_app_fs_();
34
+
35
+ this.init_top_fs_();
36
+ } else {
37
+ this.init_top_fs_();
38
+ }
39
+
40
+ this.initializeSocket();
41
+ }
42
+
43
+ init_app_fs_ () {
44
+ this.fs_nocache_ = new PostMessageFilesystem({
45
+ messageTarget: globalThis.parent,
46
+ rpc: this._.context.util.rpc,
47
+ }).as(TFilesystem);
48
+ this.filesystem = this.fs_nocache_;
49
+ }
50
+ init_top_fs_ () {
51
+ const api_info = this._.context.services.get('api-access').get_api_info();
52
+ this.fs_nocache_ = new PuterAPIFilesystem({ api_info }).as(TFilesystem);
53
+ this.fs_cache_ = new CachedFilesystem({ delegate: this.fs_nocache_ }).as(TFilesystem);
54
+ // this.filesystem = this.fs_nocache;
55
+ this.fs_proxy_ = new ProxyFilesystem({ delegate: this.fs_nocache_ });
56
+ this.filesystem = this.fs_proxy_.as(TFilesystem);
57
+ }
58
+
59
+ cache_on () {
60
+ this.fs_proxy_.delegate = this.fs_cache_;
61
+ }
62
+ cache_off () {
63
+ this.fs_proxy_.delegate = this.fs_nocache_;
64
+ }
65
+
66
+ async initializeSocket () {
67
+ if (this.socket) {
68
+ this.socket.disconnect();
69
+ }
70
+
71
+ const svc_apiAccess = this._.context.services.get('api-access');
72
+ const api_info = svc_apiAccess.get_api_info();
73
+
74
+ if ( api_info.api_origin === undefined ) {
75
+ // This will get called again later with updated information
76
+ return;
77
+ }
78
+
79
+ this.socket = io(api_info.api_origin, {
80
+ auth: { auth_token: api_info.auth_token }
81
+ });
82
+
83
+ this.bindSocketEvents();
84
+ }
85
+
86
+ bindSocketEvents() {
87
+ this.socket.on('connect', () => {
88
+ if(puter.debugMode)
89
+ console.log('FileSystem Socket: Connected', this.socket.id);
90
+ });
91
+
92
+ this.socket.on('disconnect', () => {
93
+ if(puter.debugMode)
94
+ console.log('FileSystem Socket: Disconnected');
95
+ });
96
+
97
+ this.socket.on('reconnect', (attempt) => {
98
+ if(puter.debugMode)
99
+ console.log('FileSystem Socket: Reconnected', this.socket.id);
100
+ });
101
+
102
+ this.socket.on('reconnect_attempt', (attempt) => {
103
+ if(puter.debugMode)
104
+ console.log('FileSystem Socket: Reconnection Attemps', attempt);
105
+ });
106
+
107
+ this.socket.on('reconnect_error', (error) => {
108
+ if(puter.debugMode)
109
+ console.log('FileSystem Socket: Reconnection Error', error);
110
+ });
111
+
112
+ this.socket.on('reconnect_failed', () => {
113
+ if(puter.debugMode)
114
+ console.log('FileSystem Socket: Reconnection Failed');
115
+ });
116
+
117
+ this.socket.on('error', (error) => {
118
+ if(puter.debugMode)
119
+ console.error('FileSystem Socket Error:', error);
120
+ });
121
+ }
122
+ }
@@ -0,0 +1,20 @@
1
+ import putility from "@heyputer/putility";
2
+
3
+ /**
4
+ * Runs commands on the special `globalThis.when_puter_happens` global, for
5
+ * situations where the `puter` global doesn't exist soon enough.
6
+ */
7
+ export class NoPuterYetService extends putility.concepts.Service {
8
+ _init () {
9
+ if ( ! globalThis.when_puter_happens ) return;
10
+ if ( puter && puter.env !== 'gui' ) return;
11
+
12
+ if ( ! Array.isArray(globalThis.when_puter_happens) ) {
13
+ globalThis.when_puter_happens = [globalThis.when_puter_happens];
14
+ }
15
+
16
+ for ( const fn of globalThis.when_puter_happens ) {
17
+ fn({ context: this._.context });
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,44 @@
1
+ import putility from "@heyputer/putility";
2
+
3
+ const TeePromise = putility.libs.promise.TeePromise;
4
+
5
+ /**
6
+ * Manages message events from the window object.
7
+ */
8
+ export class XDIncomingService extends putility.concepts.Service {
9
+ _construct () {
10
+ this.filter_listeners_ = [];
11
+ this.tagged_listeners_ = {};
12
+ }
13
+
14
+ _init () {
15
+ globalThis.addEventListener('message', async event => {
16
+ for ( const fn of this.filter_listeners_ ) {
17
+ const tp = new TeePromise();
18
+ fn(event, tp);
19
+ if ( await tp ) return;
20
+ }
21
+
22
+ const data = event.data;
23
+
24
+ const tag = data.$;
25
+ if ( ! tag ) return;
26
+ if ( ! this.tagged_listeners_[tag] ) return;
27
+
28
+ for ( const fn of this.tagged_listeners_[tag] ) {
29
+ fn({ data, source: event.source });
30
+ }
31
+ });
32
+ }
33
+
34
+ register_filter_listener (fn) {
35
+ this.filter_listeners_.push(fn);
36
+ }
37
+
38
+ register_tagged_listener (tag, fn) {
39
+ if ( ! this.tagged_listeners_[tag] ) {
40
+ this.tagged_listeners_[tag] = [];
41
+ }
42
+ this.tagged_listeners_[tag].push(fn);
43
+ }
44
+ }