@pylonsync/functions 0.22.47 → 0.22.58

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,343 @@
1
+ // The offline service worker, served at `/pylon-sw.js` for apps that set
2
+ // `buildManifest({ offline })`.
3
+ //
4
+ // The client bundler writes the worker into the client build with this
5
+ // build's config inlined (`renderServiceWorker`). The worker body is the
6
+ // source of `pylonServiceWorker` itself, serialized with `toString()`, so
7
+ // the tests in offline-sw.test.ts run the same code the browser runs. That
8
+ // imposes one rule on this file: `pylonServiceWorker` and `classifyRequest`
9
+ // may not reference anything outside their own bodies (no imports, no
10
+ // module-level helpers).
11
+ //
12
+ // What it does:
13
+ // - install: precache the build's client files for the offline routes,
14
+ // the web app manifest and the icons, then fetch each static offline
15
+ // route's HTML ("shell") with `X-Pylon-Offline-Shell: 1` and keep it when
16
+ // the runtime answers `Pylon-Offline-Shell: 1` (the HTML is the same for
17
+ // every signed-in user).
18
+ // - navigations: network first. When the network fails (or, for an offline
19
+ // route, takes longer than `navigationTimeoutMs`), answer with the
20
+ // route's shell, else the start URL's shell. The client runtime renders
21
+ // the real URL from the local replica.
22
+ // - build files: cache first. They are content-hashed, so a cached copy
23
+ // is always correct.
24
+ // - everything else (API, auth, sync, dev endpoints): not intercepted, never
25
+ // cached. Data comes from the sync engine, not the HTTP cache.
26
+ // - activate: delete caches from other builds. A new deploy installs a new
27
+ // worker, which waits until every tab of the app is closed (the next
28
+ // launch) before it takes over, or until the page posts
29
+ // `{ type: "pylon:skip-waiting" }` (`useAppUpdate().apply()` in
30
+ // @pylonsync/react does, after the outbox is empty).
31
+ // - messages: `pylon:session` (the signed-in bit, see below),
32
+ // `pylon:skip-waiting`, and `pylon:version`, which answers on the
33
+ // message's port with `{ type: "pylon:version", version }`. The page asks
34
+ // a waiting worker for its version this way.
35
+
36
+ /** The build-specific values inlined into `/pylon-sw.js`. */
37
+ export interface ServiceWorkerConfig {
38
+ /** Hash of everything below. Cache names carry it. */
39
+ version: string;
40
+ /** The client build's public prefix (`/_pylon/build/`, or a CDN URL). */
41
+ buildPrefix: string;
42
+ /** URLs to keep at install: the offline routes' build files, the web app
43
+ * manifest, the icons. */
44
+ precache: string[];
45
+ /** Offline routes without dynamic segments: their HTML is kept at install. */
46
+ shells: string[];
47
+ /** Every offline route pattern (`/leads/[id]` included). */
48
+ routes: string[];
49
+ /** Path of the start URL; its shell answers any navigation with no
50
+ * better match. */
51
+ startPath: string;
52
+ /** How long a navigation to an offline route waits for the network before
53
+ * the worker answers with the shell. */
54
+ navigationTimeoutMs: number;
55
+ }
56
+
57
+ /** What the worker does with one request. */
58
+ export type RequestKind =
59
+ /** A page load: network first, shell when offline. */
60
+ | "navigate"
61
+ /** A file of the client build: cache first. */
62
+ | "build"
63
+ /** The web app manifest or an icon: cache first. */
64
+ | "precached"
65
+ /** Not intercepted, never cached. */
66
+ | "network";
67
+
68
+ /**
69
+ * Classify one request. Pure; `self.location.origin` is passed in.
70
+ */
71
+ export function classifyRequest(
72
+ request: { url: string; method: string; mode: string },
73
+ config: ServiceWorkerConfig,
74
+ origin: string,
75
+ ): RequestKind {
76
+ if (request.method !== "GET") return "network";
77
+ let url: URL;
78
+ try {
79
+ url = new URL(request.url);
80
+ } catch {
81
+ return "network";
82
+ }
83
+ const sameOrigin = url.origin === origin;
84
+ if (sameOrigin) {
85
+ const path = url.pathname;
86
+ // Data and control endpoints. Data reaches the page through the sync
87
+ // engine; a cached API answer would be a stale copy of the replica, and
88
+ // a cached auth answer could sign in the wrong person.
89
+ const neverCached = [
90
+ "/api/",
91
+ "/auth/",
92
+ "/_pylon/dev/",
93
+ "/studio",
94
+ "/events",
95
+ "/health",
96
+ "/metrics",
97
+ "/.well-known/",
98
+ "/_pylon/version",
99
+ ];
100
+ for (const prefix of neverCached) {
101
+ if (path === prefix.replace(/\/$/, "") || path.startsWith(prefix)) return "network";
102
+ }
103
+ if (path === "/pylon-sw.js") return "network";
104
+ if (request.mode === "navigate") return "navigate";
105
+ if (path.startsWith("/_pylon/build/")) return "build";
106
+ }
107
+ if (!sameOrigin && config.buildPrefix && url.href.startsWith(config.buildPrefix)) {
108
+ return "build";
109
+ }
110
+ if (sameOrigin && config.precache.indexOf(url.pathname) !== -1) return "precached";
111
+ return "network";
112
+ }
113
+
114
+ /**
115
+ * The worker. `sw` is the ServiceWorkerGlobalScope (`self`); passing it in
116
+ * keeps the body free of globals so tests can supply their own. `classify`
117
+ * is `classifyRequest`, passed in for the same reason.
118
+ */
119
+ export function pylonServiceWorker(
120
+ sw: any,
121
+ config: ServiceWorkerConfig,
122
+ classify: typeof classifyRequest,
123
+ ): void {
124
+ const ASSETS = "pylon-" + config.version + "-assets";
125
+ const SHELLS = "pylon-" + config.version + "-shells";
126
+ const STATE_KEY = "/__pylon_sw_state";
127
+ const SHELL_REQUEST_HEADER = "X-Pylon-Offline-Shell";
128
+ const SHELL_RESPONSE_HEADER = "Pylon-Offline-Shell";
129
+
130
+ function warn(message: string): void {
131
+ try {
132
+ sw.console.warn("[pylon offline] " + message);
133
+ } catch {
134
+ // No console in this scope.
135
+ }
136
+ }
137
+
138
+ function splitPath(p: string): string[] {
139
+ const trimmed = p.replace(/\/+$/, "");
140
+ return trimmed === "" ? [] : trimmed.slice(1).split("/");
141
+ }
142
+
143
+ // Does `pathname` match a route pattern (`/leads/[id]`, `/docs/[...slug]`)?
144
+ function matchesPattern(pattern: string, pathname: string): boolean {
145
+ const pat = splitPath(pattern);
146
+ const target = splitPath(pathname);
147
+ for (let i = 0; i < pat.length; i++) {
148
+ const seg = pat[i];
149
+ if (seg.startsWith("[...") && seg.endsWith("]")) return target.length > i;
150
+ if (seg.startsWith("[[...") && seg.endsWith("]]")) return true;
151
+ if (i >= target.length) return false;
152
+ if (seg.startsWith("[") && seg.endsWith("]")) continue;
153
+ if (seg !== target[i]) return false;
154
+ }
155
+ return pat.length === target.length;
156
+ }
157
+
158
+ function isOfflineRoute(pathname: string): boolean {
159
+ for (const route of config.routes) {
160
+ if (matchesPattern(route, pathname)) return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ function normalizePath(pathname: string): string {
166
+ return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
167
+ }
168
+
169
+ // Fetch each static offline route's HTML and keep the ones the runtime
170
+ // marks as identical for every signed-in user. Returns false when the
171
+ // network failed, so the caller does not record the refresh as done.
172
+ async function refreshShells(): Promise<boolean> {
173
+ const cache = await sw.caches.open(SHELLS);
174
+ let reached = false;
175
+ for (const path of config.shells) {
176
+ let res: any;
177
+ try {
178
+ res = await sw.fetch(path, {
179
+ headers: { [SHELL_REQUEST_HEADER]: "1" },
180
+ credentials: "same-origin",
181
+ cache: "no-store",
182
+ });
183
+ } catch {
184
+ continue;
185
+ }
186
+ reached = true;
187
+ const verdict = res.headers.get(SHELL_RESPONSE_HEADER);
188
+ if (res.ok && !res.redirected && verdict === "1") {
189
+ await cache.put(path, res);
190
+ } else if (res.redirected || (verdict && verdict !== "1") || res.status === 404) {
191
+ // A definite answer that this page is not a shell for the current
192
+ // session (a redirect to sign-in, a page that reads the user): drop
193
+ // the old copy so it is not shown to the wrong session. A server
194
+ // error keeps the old copy.
195
+ await cache.delete(path);
196
+ warn(
197
+ path +
198
+ " is not kept for offline use: " +
199
+ (verdict || (res.redirected ? "redirected to " + res.url : "status " + res.status)),
200
+ );
201
+ } else {
202
+ warn(path + " is not kept for offline use: status " + res.status);
203
+ }
204
+ }
205
+ return reached;
206
+ }
207
+
208
+ // The client posts its signed-in state after every page load. The shells
209
+ // carry that one bit, so refetch them when it changed.
210
+ async function syncSession(signedIn: boolean): Promise<void> {
211
+ const cache = await sw.caches.open(SHELLS);
212
+ const stored = await cache.match(STATE_KEY);
213
+ if (stored) {
214
+ const state = await stored.json();
215
+ if (state && state.signedIn === signedIn) return;
216
+ }
217
+ if (await refreshShells()) {
218
+ await cache.put(
219
+ STATE_KEY,
220
+ new sw.Response(JSON.stringify({ signedIn }), {
221
+ headers: { "Content-Type": "application/json" },
222
+ }),
223
+ );
224
+ }
225
+ }
226
+
227
+ async function shellFor(pathname: string): Promise<any> {
228
+ const cache = await sw.caches.open(SHELLS);
229
+ const exact = await cache.match(normalizePath(pathname));
230
+ if (exact) return exact;
231
+ return cache.match(config.startPath);
232
+ }
233
+
234
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
235
+ return new Promise((resolve, reject) => {
236
+ const timer = sw.setTimeout(() => reject(new Error("timeout")), ms);
237
+ promise.then(
238
+ (v) => {
239
+ sw.clearTimeout(timer);
240
+ resolve(v);
241
+ },
242
+ (e) => {
243
+ sw.clearTimeout(timer);
244
+ reject(e);
245
+ },
246
+ );
247
+ });
248
+ }
249
+
250
+ async function handleNavigation(request: any): Promise<any> {
251
+ const pathname = new URL(request.url).pathname;
252
+ const offlineRoute = isOfflineRoute(pathname);
253
+ const network = sw.fetch(request);
254
+ try {
255
+ // Only an offline route has a page to show instead, so only an
256
+ // offline route stops waiting for a slow network.
257
+ return await (offlineRoute
258
+ ? withTimeout(network, config.navigationTimeoutMs)
259
+ : network);
260
+ } catch (err) {
261
+ const shell = await shellFor(pathname);
262
+ if (shell) return shell;
263
+ // Nothing kept: wait for the network after all (a slow answer beats
264
+ // the browser's offline page), and let a real failure through.
265
+ return network;
266
+ }
267
+ }
268
+
269
+ async function cacheFirst(request: any): Promise<any> {
270
+ const cache = await sw.caches.open(ASSETS);
271
+ const hit = await cache.match(request);
272
+ if (hit) return hit;
273
+ const res = await sw.fetch(request);
274
+ // Keep build files the page loads lazily (a dynamic import, an image
275
+ // the page imports), so they work offline after their first use.
276
+ if (res && res.ok) await cache.put(request, res.clone());
277
+ return res;
278
+ }
279
+
280
+ sw.addEventListener("install", (event: any) => {
281
+ event.waitUntil(
282
+ (async () => {
283
+ const cache = await sw.caches.open(ASSETS);
284
+ // All-or-nothing: a build file that fails to download fails the
285
+ // install, and the previous worker keeps serving the previous build.
286
+ await cache.addAll(config.precache);
287
+ await refreshShells();
288
+ })(),
289
+ );
290
+ });
291
+
292
+ sw.addEventListener("activate", (event: any) => {
293
+ event.waitUntil(
294
+ (async () => {
295
+ const names: string[] = await sw.caches.keys();
296
+ await Promise.all(
297
+ names
298
+ .filter(
299
+ (n) => n.indexOf("pylon-") === 0 && n !== ASSETS && n !== SHELLS,
300
+ )
301
+ .map((n) => sw.caches.delete(n)),
302
+ );
303
+ // Control the tab that installed the first worker, so the app works
304
+ // offline after one visit. With an older worker this runs only after
305
+ // every tab closed, so it never swaps a build mid-session.
306
+ await sw.clients.claim();
307
+ })(),
308
+ );
309
+ });
310
+
311
+ sw.addEventListener("fetch", (event: any) => {
312
+ const request = event.request;
313
+ const kind = classify(request, config, sw.location.origin);
314
+ if (kind === "navigate") event.respondWith(handleNavigation(request));
315
+ else if (kind === "build" || kind === "precached") event.respondWith(cacheFirst(request));
316
+ });
317
+
318
+ sw.addEventListener("message", (event: any) => {
319
+ const data = event.data;
320
+ if (!data) return;
321
+ if (data.type === "pylon:session" && typeof data.signedIn === "boolean") {
322
+ event.waitUntil(syncSession(data.signedIn));
323
+ } else if (data.type === "pylon:skip-waiting") {
324
+ event.waitUntil(sw.skipWaiting());
325
+ } else if (data.type === "pylon:version") {
326
+ const port = event.ports && event.ports[0];
327
+ if (port) port.postMessage({ type: "pylon:version", version: config.version });
328
+ }
329
+ });
330
+ }
331
+
332
+ /** The `/pylon-sw.js` source for one build. */
333
+ export function renderServiceWorker(config: ServiceWorkerConfig): string {
334
+ return (
335
+ "// Generated by Pylon for this build. Do not edit.\n(" +
336
+ pylonServiceWorker.toString() +
337
+ ")(self, " +
338
+ JSON.stringify(config) +
339
+ ", " +
340
+ classifyRequest.toString() +
341
+ ");\n"
342
+ );
343
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Wire shapes of ctx.files.info and ctx.files.read (runtime.ts). Same
3
+ * child-process harness as runtime-vector.test.ts: the probe emits frames,
4
+ * and for read the test replies so the bytes come back decoded.
5
+ */
6
+ import { expect, test } from "bun:test";
7
+ import { mkdtempSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
12
+
13
+ const SCRIPT = `
14
+ import { buildActionFiles, buildFiles } from ${JSON.stringify(RUNTIME)};
15
+
16
+ buildFiles("c_i").info("file_1").catch(() => {});
17
+ buildActionFiles("c_r").read("file_2").then((f) => {
18
+ process.stdout.write(JSON.stringify({ probe: "read", id: f.id, size: f.size, contentType: f.contentType, text: new TextDecoder().decode(f.data) }) + "\\n");
19
+ process.exit(0);
20
+ }).catch((e) => { console.log(JSON.stringify({ probe: "error", message: String(e) })); process.exit(1); });
21
+ setTimeout(() => process.exit(2), 3000);
22
+ `;
23
+
24
+ test("info and read emit files_op frames, and read decodes the bytes", async () => {
25
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-files-"));
26
+ const scriptPath = join(dir, "probe.ts");
27
+ writeFileSync(scriptPath, SCRIPT);
28
+ const proc = Bun.spawn(["bun", scriptPath], { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
29
+ const frames: Record<string, any>[] = [];
30
+ let replied = false;
31
+ const decoder = new TextDecoder();
32
+ let buffered = "";
33
+ for await (const chunk of proc.stdout) {
34
+ buffered += decoder.decode(chunk);
35
+ const lines = buffered.split("\n");
36
+ buffered = lines.pop() ?? "";
37
+ for (const line of lines) {
38
+ if (!line.trim().startsWith("{")) continue;
39
+ const frame = JSON.parse(line);
40
+ frames.push(frame);
41
+ if (frame.type === "files_op" && frame.op === "read" && !replied) {
42
+ replied = true;
43
+ const data = Buffer.from("hello file").toString("base64");
44
+ proc.stdin.write(
45
+ JSON.stringify({ type: "result", call_id: frame.call_id, data: { id: "file_2", contentType: "text/plain", size: 10, data_base64: data } }) + "\n",
46
+ );
47
+ proc.stdin.flush();
48
+ }
49
+ }
50
+ }
51
+ await proc.exited;
52
+ expect(frames.find((f) => f.type === "files_op" && f.op === "info")).toMatchObject({ call_id: "c_i", file_id: "file_1" });
53
+ expect(frames.find((f) => f.type === "files_op" && f.op === "read")).toMatchObject({ call_id: "c_r", file_id: "file_2" });
54
+ expect(frames.find((f) => f.probe === "read")).toEqual({ probe: "read", id: "file_2", size: 10, contentType: "text/plain", text: "hello file" });
55
+ });
package/src/runtime.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  EmailOptions,
24
24
  EmailSender,
25
25
  Files,
26
+ FileInfo,
26
27
  ActionFiles,
27
28
  Crdt,
28
29
  StoredFile,
@@ -622,8 +623,8 @@ function buildShards(callId: string): Shards {
622
623
  };
623
624
  }
624
625
 
625
- /** `ctx.files` — signed download URLs, minted host-side. */
626
- function buildFiles(callId: string): Files {
626
+ /** `ctx.files` — signed download URLs, minted host-side, and what the host knows of a file. */
627
+ export function buildFiles(callId: string): Files {
627
628
  return {
628
629
  async signedUrl(fileId, opts) {
629
630
  return rpc(callId, {
@@ -632,11 +633,14 @@ function buildFiles(callId: string): Files {
632
633
  ttl_secs: opts?.ttlSecs,
633
634
  }) as Promise<string>;
634
635
  },
636
+ async info(fileId) {
637
+ return rpc(callId, { type: "files_op", op: "info", file_id: fileId }) as Promise<FileInfo | null>;
638
+ },
635
639
  };
636
640
  }
637
641
 
638
- /** `ctx.files` in an action: {@link buildFiles} plus store and delete. */
639
- function buildActionFiles(callId: string): ActionFiles {
642
+ /** `ctx.files` in an action: {@link buildFiles} plus store, read and delete. */
643
+ export function buildActionFiles(callId: string): ActionFiles {
640
644
  return {
641
645
  ...buildFiles(callId),
642
646
  async store(data, opts) {
@@ -667,6 +671,15 @@ function buildActionFiles(callId: string): ActionFiles {
667
671
  file_id: fileId,
668
672
  }) as Promise<{ deleted: boolean }>;
669
673
  },
674
+ async read(fileId) {
675
+ const out = (await rpc(callId, { type: "files_op", op: "read", file_id: fileId })) as {
676
+ id: string;
677
+ contentType: string | null;
678
+ size: number;
679
+ data_base64: string;
680
+ };
681
+ return { id: out.id, contentType: out.contentType ?? null, size: out.size, data: fromBase64(out.data_base64) };
682
+ },
670
683
  };
671
684
  }
672
685
 
@@ -0,0 +1,70 @@
1
+ // Namespace barrels (`radix-ui`): parsing the entry, and rewriting named
2
+ // imports of it into imports of the packages it re-exports.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { parseNamespaceBarrel, rewriteBarrelImports } from "./ssr-barrel-imports";
6
+
7
+ const RADIX = `// src/index.ts
8
+ "use client";
9
+ import * as AlertDialog from "@radix-ui/react-alert-dialog";
10
+ import * as Dialog from "@radix-ui/react-dialog";
11
+ import * as unstable_PasswordToggleField from "@radix-ui/react-password-toggle-field";
12
+ export {
13
+ AlertDialog,
14
+ Dialog,
15
+ unstable_PasswordToggleField
16
+ };
17
+ //# sourceMappingURL=index.mjs.map
18
+ `;
19
+
20
+ describe("parseNamespaceBarrel", () => {
21
+ test("maps each exported namespace to its package", () => {
22
+ expect(Object.fromEntries(parseNamespaceBarrel(RADIX)!)).toEqual({
23
+ AlertDialog: "@radix-ui/react-alert-dialog",
24
+ Dialog: "@radix-ui/react-dialog",
25
+ unstable_PasswordToggleField: "@radix-ui/react-password-toggle-field",
26
+ });
27
+ });
28
+
29
+ test("anything else is not a barrel", () => {
30
+ expect(parseNamespaceBarrel(`export const x = 1;`)).toBeNull();
31
+ expect(parseNamespaceBarrel(`import * as A from "a"; export { A }; console.log(1);`)).toBeNull();
32
+ expect(parseNamespaceBarrel(`import { a } from "a"; export { a };`)).toBeNull();
33
+ expect(parseNamespaceBarrel(`import * as A from "a"; export { A, B };`)).toBeNull();
34
+ expect(parseNamespaceBarrel(``)).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("rewriteBarrelImports", () => {
39
+ const map = parseNamespaceBarrel(RADIX)!;
40
+ const barrels = (spec: string) => (spec === "radix-ui" ? map : null);
41
+ const same = (_barrel: string, spec: string) => spec;
42
+
43
+ test("one namespace import per name, keeping local names", () => {
44
+ expect(
45
+ rewriteBarrelImports(`import { Dialog as SheetPrimitive } from "radix-ui";`, barrels, same),
46
+ ).toBe(`import * as SheetPrimitive from "@radix-ui/react-dialog";`);
47
+ expect(
48
+ rewriteBarrelImports(`import {\n AlertDialog,\n Dialog,\n} from 'radix-ui'`, barrels, same),
49
+ ).toBe(`import * as AlertDialog from '@radix-ui/react-alert-dialog'; import * as Dialog from '@radix-ui/react-dialog';`);
50
+ });
51
+
52
+ test("type imports, unknown names and other packages stay", () => {
53
+ expect(rewriteBarrelImports(`import type { Dialog } from "radix-ui";`, barrels, same)).toBe(
54
+ `import type { Dialog } from "radix-ui";`,
55
+ );
56
+ expect(
57
+ rewriteBarrelImports(`import { Dialog, type Foo, Missing } from "radix-ui";`, barrels, same),
58
+ ).toBe(
59
+ `import { type Foo, Missing } from "radix-ui"; import * as Dialog from "@radix-ui/react-dialog";`,
60
+ );
61
+ const other = `import { useState } from "react";`;
62
+ expect(rewriteBarrelImports(other, barrels, same)).toBe(other);
63
+ });
64
+
65
+ test("the target specifier comes from the resolver", () => {
66
+ expect(
67
+ rewriteBarrelImports(`import { Dialog } from "radix-ui";`, barrels, () => "/abs/react-dialog/index.mjs"),
68
+ ).toBe(`import * as Dialog from "/abs/react-dialog/index.mjs";`);
69
+ });
70
+ });
@@ -0,0 +1,104 @@
1
+ // Per-route vendor code for "barrel" packages.
2
+ //
3
+ // A barrel is a package whose entry only re-exports other packages as
4
+ // namespaces, like `radix-ui`:
5
+ //
6
+ // import * as Dialog from "@radix-ui/react-dialog";
7
+ // import * as Select from "@radix-ui/react-select";
8
+ // export { Dialog, Select };
9
+ //
10
+ // With code splitting, every route that imports anything from the barrel
11
+ // reaches every package it re-exports, so the bundler puts all of them in
12
+ // one shared chunk that loads on every such route (55 KB gzip of Radix on
13
+ // the Veritas home screen, which uses one Radix component). Rewriting
14
+ //
15
+ // import { Dialog as SheetPrimitive } from "radix-ui";
16
+ //
17
+ // in app source to
18
+ //
19
+ // import * as SheetPrimitive from "@radix-ui/react-dialog";
20
+ //
21
+ // lets each route reach only the packages it uses, so the splitter keeps
22
+ // the rest out of its chunks. The client bundler applies this to app files
23
+ // (not node_modules) for every bare import whose entry is a barrel.
24
+
25
+ /**
26
+ * The namespaces a barrel entry re-exports (exported name → module
27
+ * specifier), or null when `source` is not a pure namespace barrel: any
28
+ * statement other than `import * as X from "..."` and `export { ... }` of
29
+ * those names makes it null.
30
+ */
31
+ export function parseNamespaceBarrel(source: string): Map<string, string> | null {
32
+ const code = source
33
+ .replace(/\/\*[\s\S]*?\*\//g, "")
34
+ .replace(/^\s*\/\/.*$/gm, "")
35
+ .replace(/["']use client["'];?/g, "");
36
+ const imports = new Map<string, string>();
37
+ let rest = code.replace(
38
+ /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+(["'])([^"']+)\2\s*;?/g,
39
+ (_m, local: string, _q: string, spec: string) => {
40
+ imports.set(local, spec);
41
+ return "";
42
+ },
43
+ );
44
+ const exported = new Map<string, string>();
45
+ let ok = true;
46
+ rest = rest.replace(/export\s*\{([^}]*)\}\s*;?/g, (_m, list: string) => {
47
+ for (const item of list.split(",")) {
48
+ const part = item.trim();
49
+ if (!part) continue;
50
+ const m = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
51
+ const spec = m ? imports.get(m[1]!) : undefined;
52
+ if (!m || !spec) {
53
+ ok = false;
54
+ continue;
55
+ }
56
+ exported.set(m[2] ?? m[1]!, spec);
57
+ }
58
+ return "";
59
+ });
60
+ if (!ok || rest.trim() !== "" || exported.size === 0) return null;
61
+ return exported;
62
+ }
63
+
64
+ /**
65
+ * Rewrite `import { A, B as C } from "<barrel>"` in `source` to one
66
+ * namespace import per name, from `resolveTarget(spec)` (the specifier the
67
+ * importing file should use for the re-exported module). Type-only imports
68
+ * and names the barrel does not re-export stay on the barrel import.
69
+ * `barrels` maps a bare specifier to its parsed barrel. Returns the source
70
+ * unchanged when nothing applies.
71
+ */
72
+ export function rewriteBarrelImports(
73
+ source: string,
74
+ barrels: (specifier: string) => Map<string, string> | null,
75
+ resolveTarget: (barrel: string, spec: string) => string,
76
+ ): string {
77
+ return source.replace(
78
+ /import\s+(type\s+)?\{([^}]*)\}\s*from\s*(["'])([^"'./][^"']*)\3\s*;?/g,
79
+ (whole, typeOnly: string | undefined, list: string, quote: string, specifier: string) => {
80
+ if (typeOnly) return whole;
81
+ const map = barrels(specifier);
82
+ if (!map) return whole;
83
+ const kept: string[] = [];
84
+ const lines: string[] = [];
85
+ for (const item of list.split(",")) {
86
+ const part = item.trim();
87
+ if (!part) continue;
88
+ const m = part.match(/^(type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
89
+ const target = m && !m[1] ? map.get(m[2]!) : undefined;
90
+ if (!m || !target) {
91
+ kept.push(part);
92
+ continue;
93
+ }
94
+ const local = m[3] ?? m[2]!;
95
+ lines.push(`import * as ${local} from ${quote}${resolveTarget(specifier, target)}${quote};`);
96
+ }
97
+ if (lines.length === 0) return whole;
98
+ if (kept.length > 0) {
99
+ lines.unshift(`import { ${kept.join(", ")} } from ${quote}${specifier}${quote};`);
100
+ }
101
+ return lines.join(" ");
102
+ },
103
+ );
104
+ }