@pylonsync/functions 0.11.5 → 0.12.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.
@@ -0,0 +1,430 @@
1
+ // Browser compatibility for the client bundle: syntax lowering, core-js
2
+ // polyfills, and CSS targets. Driven by the manifest's `build` block
3
+ // (`buildManifest({ build: { target, polyfill, css } })`).
4
+ //
5
+ // Bun.build cannot lower syntax to an older target, so lowering runs as a
6
+ // pass over Bun's output:
7
+ //
8
+ // 1. SWC transforms every emitted .js file with `env.targets` (syntax
9
+ // lowering + minify). In `usage` mode SWC also adds an
10
+ // `import "core-js/modules/<feature>.js"` line for each feature the file
11
+ // uses that the targets lack. The pass strips those lines and collects
12
+ // them.
13
+ // 2. The collected set (or, in `entry` mode, every feature the targets
14
+ // lack) is bundled once into `polyfills-<hash>.js`. The SSR head loads it
15
+ // before the route entry.
16
+ // 3. Every .js output gets a new name whose hash covers the transform
17
+ // settings, so a target change never serves a stale immutable file.
18
+ //
19
+ // The tools (@swc/core, core-js, browserslist, lightningcss) are resolved
20
+ // from the app, like @tailwindcss/cli. An app that sets no `build.target`
21
+ // needs none of them.
22
+
23
+ declare const Bun: {
24
+ resolveSync(specifier: string, from: string): string;
25
+ build(options: any): Promise<any>;
26
+ };
27
+
28
+ /** The manifest's `build` block, as the SDK writes it. */
29
+ export interface ManifestBuildConfig {
30
+ target?: string | string[];
31
+ polyfill?: false | "usage" | "entry";
32
+ css?: { target?: string | string[] };
33
+ sourcemap?: boolean;
34
+ server?: { bundle?: boolean; external?: string[] };
35
+ include?: string[];
36
+ }
37
+
38
+ /** Resolved client compatibility settings. `null` means Bun's output ships
39
+ * unchanged (no target set). */
40
+ export interface ClientCompat {
41
+ /** Browserslist queries for JS. Empty when only `css.target` is set. */
42
+ jsTargets: string[];
43
+ /** Browserslist queries for CSS. */
44
+ cssTargets: string[];
45
+ polyfill: false | "usage" | "entry";
46
+ sourcemap: boolean;
47
+ }
48
+
49
+ /**
50
+ * Browser versions for each ECMAScript edition target. `es2019` becomes the
51
+ * oldest browsers with full ES2019 syntax support, so SWC and Lightning CSS
52
+ * (which work from browser versions) can use it.
53
+ */
54
+ export const ES_TARGET_BROWSERS: Record<string, string[]> = {
55
+ es2018: ["chrome 64", "edge 79", "firefox 67", "safari 12", "ios 12"],
56
+ es2019: ["chrome 73", "edge 79", "firefox 67", "safari 12.1", "ios 12.2"],
57
+ es2020: ["chrome 80", "edge 80", "firefox 80", "safari 14.1", "ios 14.5"],
58
+ es2021: ["chrome 85", "edge 85", "firefox 80", "safari 14.1", "ios 14.5"],
59
+ es2022: ["chrome 94", "edge 94", "firefox 93", "safari 16.4", "ios 16.4"],
60
+ };
61
+
62
+ /**
63
+ * The oldest version of each browser that can run the Pylon client runtime.
64
+ * The runtime ships as ES modules and loads route entries with dynamic
65
+ * `import()`. Syntax lowering cannot make an older browser load a page.
66
+ */
67
+ export const RUNTIME_BROWSER_FLOOR: Record<string, number> = {
68
+ chrome: 63,
69
+ and_chr: 63,
70
+ edge: 79,
71
+ firefox: 67,
72
+ and_ff: 67,
73
+ safari: 11.1,
74
+ ios_saf: 11.3,
75
+ opera: 50,
76
+ op_mob: 46,
77
+ samsung: 8,
78
+ android: 63,
79
+ };
80
+
81
+ /** Normalize a `target` value to browserslist queries. Throws on a value the
82
+ * build cannot honor. */
83
+ export function targetQueries(target: string | string[], field: string): string[] {
84
+ const list = Array.isArray(target) ? target : [target];
85
+ if (list.length === 0) throw new Error(`${field}: must not be empty`);
86
+ const out: string[] = [];
87
+ for (const raw of list) {
88
+ if (typeof raw !== "string" || raw.trim() === "") {
89
+ throw new Error(`${field}: every entry must be a non-empty string`);
90
+ }
91
+ const t = raw.trim();
92
+ const es = t.match(/^es(\d{4})$/i);
93
+ if (es) {
94
+ const key = `es${es[1]}`;
95
+ const browsers = ES_TARGET_BROWSERS[key];
96
+ if (!browsers) {
97
+ throw new Error(
98
+ `${field}: "${t}" is not supported. Use one of ${Object.keys(ES_TARGET_BROWSERS).join(", ")}, or browserslist queries. ` +
99
+ `Targets below es2018 cannot run the Pylon client runtime (ES modules and dynamic import()).`,
100
+ );
101
+ }
102
+ out.push(...browsers);
103
+ } else {
104
+ out.push(t);
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Resolve the client compatibility settings from the manifest's `build`
112
+ * block. Returns null when no JS or CSS target is set. `polyfill` without
113
+ * `target` is an error: it needs the browsers to polyfill for.
114
+ */
115
+ export function resolveClientCompat(
116
+ build: ManifestBuildConfig | undefined,
117
+ ): ClientCompat | null {
118
+ if (!build) return null;
119
+ const polyfill = build.polyfill ?? false;
120
+ if (polyfill !== false && polyfill !== "usage" && polyfill !== "entry") {
121
+ throw new Error(`build.polyfill: must be false, "usage", or "entry"`);
122
+ }
123
+ const sourcemap = build.sourcemap === true;
124
+ if (build.target === undefined) {
125
+ if (polyfill !== false) {
126
+ throw new Error(`build.polyfill needs build.target (the browsers to polyfill for)`);
127
+ }
128
+ if (build.css?.target === undefined) return null;
129
+ const cssTargets = targetQueries(build.css.target, "build.css.target");
130
+ return { jsTargets: [], cssTargets, polyfill: false, sourcemap };
131
+ }
132
+ const jsTargets = targetQueries(build.target, "build.target");
133
+ const cssTargets =
134
+ build.css?.target !== undefined
135
+ ? targetQueries(build.css.target, "build.css.target")
136
+ : jsTargets;
137
+ return { jsTargets, cssTargets, polyfill, sourcemap };
138
+ }
139
+
140
+ /** The npm package name of a module specifier (`@swc/core/x` → `@swc/core`). */
141
+ function packageName(spec: string): string {
142
+ const parts = spec.split("/");
143
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
144
+ }
145
+
146
+ /** Load a build tool from the app's dependencies, with an install hint. */
147
+ async function loadAppTool(cwd: string, spec: string, why: string): Promise<any> {
148
+ let resolved: string;
149
+ try {
150
+ resolved = Bun.resolveSync(spec, cwd);
151
+ } catch {
152
+ throw new Error(
153
+ `${why} needs "${packageName(spec)}" in the app's dependencies. Run: bun add -d ${packageName(spec)}`,
154
+ );
155
+ }
156
+ const mod = await import(resolved);
157
+ // CommonJS packages (browserslist) arrive as `{ default: fn }`.
158
+ return typeof mod.default === "function" ? mod.default : mod;
159
+ }
160
+
161
+ /** The browsers (from a browserslist result) that cannot run the client
162
+ * runtime. Browsers with no known floor are included. */
163
+ export function browsersBelowFloor(browsers: string[]): string[] {
164
+ const below: string[] = [];
165
+ for (const b of browsers) {
166
+ const [name, version] = b.split(" ");
167
+ const floor = RUNTIME_BROWSER_FLOOR[name];
168
+ // A range such as "15.2-15.3": compare its lower bound.
169
+ const v = parseFloat((version ?? "").split("-")[0]);
170
+ if (floor === undefined || Number.isNaN(v) || v < floor) below.push(b);
171
+ }
172
+ return below;
173
+ }
174
+
175
+ const CORE_JS_IMPORT_RE = /import\s*["'](core-js\/modules\/[^"']+)["'];?/g;
176
+
177
+ /** Remove core-js feature imports from `code` and add them to `into`. */
178
+ export function extractCoreJsImports(code: string, into: Set<string>): string {
179
+ return code.replace(CORE_JS_IMPORT_RE, (_m, spec: string) => {
180
+ into.add(spec);
181
+ return "";
182
+ });
183
+ }
184
+
185
+ /** Give a hashed file name (`name-<hash>.js`) a new hash derived from its old
186
+ * one and `salt`. Names without a hash are returned unchanged. */
187
+ export function rehashName(
188
+ base: string,
189
+ salt: string,
190
+ hasher: (s: string) => string,
191
+ ): string {
192
+ const m = base.match(/^(.*)-([A-Za-z0-9]+)\.js$/);
193
+ if (!m) return base;
194
+ return `${m[1]}-${hasher(`${m[2]}:${salt}`).slice(0, 10)}.js`;
195
+ }
196
+
197
+ export interface CompatResult {
198
+ /** Old outdir-relative path → new outdir-relative path, for every renamed
199
+ * output. */
200
+ renamed: Map<string, string>;
201
+ /** Polyfill bundle, outdir-relative. Absent when polyfill is off or the
202
+ * targets need none. */
203
+ polyfills?: string;
204
+ /** Browsers the targets include that cannot run the client runtime. */
205
+ unsupported: string[];
206
+ }
207
+
208
+ /**
209
+ * Lower every .js output under `outdir` to the JS targets, collect and bundle
210
+ * polyfills, and rename the outputs. `jsFiles` are outdir-relative,
211
+ * "/"-separated paths of Bun's .js outputs.
212
+ */
213
+ export async function applyClientCompat(opts: {
214
+ fs: any;
215
+ path: any;
216
+ cwd: string;
217
+ outdir: string;
218
+ jsFiles: string[];
219
+ compat: ClientCompat;
220
+ }): Promise<CompatResult> {
221
+ const { fs, path, cwd, outdir, jsFiles, compat } = opts;
222
+ const renamed = new Map<string, string>();
223
+ if (compat.jsTargets.length === 0) return { renamed, unsupported: [] };
224
+
225
+ const why = "build.target";
226
+ const swc = await loadAppTool(cwd, "@swc/core", why);
227
+ const browserslist = await loadAppTool(cwd, "browserslist", why);
228
+ // The resolved browsers, not the queries: `defaults` or `last 2 versions`
229
+ // resolve to new browsers when browserslist's data updates, and the output
230
+ // (and so the file names) must change with them.
231
+ const browsers: string[] = browserslist(compat.jsTargets);
232
+ const unsupported = browsersBelowFloor(browsers);
233
+
234
+ let coreJsVersion = "";
235
+ if (compat.polyfill !== false) {
236
+ let pkgPath: string;
237
+ try {
238
+ pkgPath = Bun.resolveSync("core-js/package.json", cwd);
239
+ } catch {
240
+ throw new Error(
241
+ `build.polyfill needs "core-js" in the app's dependencies. Run: bun add core-js`,
242
+ );
243
+ }
244
+ const v = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version as string;
245
+ coreJsVersion = v.split(".").slice(0, 2).join(".");
246
+ }
247
+
248
+ const envFor = (mode: "usage" | "entry" | undefined) => ({
249
+ targets: compat.jsTargets,
250
+ ...(mode ? { mode, coreJs: coreJsVersion } : {}),
251
+ });
252
+
253
+ const features = new Set<string>();
254
+ for (const rel of jsFiles) {
255
+ const abs = path.join(outdir, rel);
256
+ const code = fs.readFileSync(abs, "utf8");
257
+ const mapPath = `${abs}.map`;
258
+ const inputMap =
259
+ compat.sourcemap && fs.existsSync(mapPath)
260
+ ? fs.readFileSync(mapPath, "utf8")
261
+ : undefined;
262
+ const out = await swc.transform(code, {
263
+ filename: path.basename(abs),
264
+ isModule: true,
265
+ sourceMaps: compat.sourcemap,
266
+ ...(inputMap ? { inputSourceMap: inputMap } : {}),
267
+ jsc: {
268
+ parser: { syntax: "ecmascript" },
269
+ minify: { compress: true, mangle: true },
270
+ },
271
+ env: envFor(compat.polyfill === "usage" ? "usage" : undefined),
272
+ module: { type: "es6" },
273
+ minify: true,
274
+ });
275
+ const lowered = extractCoreJsImports(out.code, features);
276
+ fs.writeFileSync(abs, stripSourceMapComment(lowered), "utf8");
277
+ if (out.map) fs.writeFileSync(mapPath, out.map, "utf8");
278
+ }
279
+
280
+ if (compat.polyfill === "entry") {
281
+ const out = await swc.transform(`import "core-js/stable";`, {
282
+ filename: "polyfills-entry.js",
283
+ isModule: true,
284
+ jsc: { parser: { syntax: "ecmascript" } },
285
+ env: envFor("entry"),
286
+ module: { type: "es6" },
287
+ });
288
+ extractCoreJsImports(out.code, features);
289
+ }
290
+
291
+ const cryptoMod: any = await import("node:crypto");
292
+ const hasher = (s: string) =>
293
+ cryptoMod.createHash("sha256").update(s).digest("hex");
294
+ const salt = JSON.stringify([
295
+ browsers,
296
+ compat.polyfill,
297
+ compat.sourcemap,
298
+ swc.version ?? "",
299
+ coreJsVersion,
300
+ ]);
301
+
302
+ // Rename every .js output, then rewrite the references between them. Hashed
303
+ // names are unique tokens, so a plain string replace is exact.
304
+ const baseRenames = new Map<string, string>();
305
+ for (const rel of jsFiles) {
306
+ const base = path.basename(rel);
307
+ const next = rehashName(base, salt, hasher);
308
+ if (next !== base) baseRenames.set(base, next);
309
+ }
310
+ for (const rel of jsFiles) {
311
+ const abs = path.join(outdir, rel);
312
+ let code = fs.readFileSync(abs, "utf8");
313
+ for (const [from, to] of baseRenames) {
314
+ if (code.includes(from)) code = code.split(from).join(to);
315
+ }
316
+ const base = path.basename(rel);
317
+ const nextBase = baseRenames.get(base) ?? base;
318
+ const nextRel = rel.slice(0, rel.length - base.length) + nextBase;
319
+ const nextAbs = path.join(outdir, nextRel);
320
+ if (fs.existsSync(`${abs}.map`)) {
321
+ code += `//# sourceMappingURL=${nextBase}.map\n`;
322
+ if (nextAbs !== abs) fs.renameSync(`${abs}.map`, `${nextAbs}.map`);
323
+ }
324
+ fs.writeFileSync(nextAbs, code, "utf8");
325
+ if (nextAbs !== abs) {
326
+ fs.rmSync(abs);
327
+ renamed.set(rel, nextRel);
328
+ }
329
+ }
330
+
331
+ let polyfills: string | undefined;
332
+ if (features.size > 0) {
333
+ polyfills = await buildPolyfills({
334
+ fs,
335
+ path,
336
+ cwd,
337
+ outdir,
338
+ features: [...features].sort(),
339
+ swc,
340
+ env: envFor(undefined),
341
+ hasher,
342
+ salt,
343
+ });
344
+ }
345
+
346
+ return { renamed, polyfills, unsupported };
347
+ }
348
+
349
+ function stripSourceMapComment(code: string): string {
350
+ return code.replace(/\n?\/\/# sourceMappingURL=\S+\s*$/, "") + "\n";
351
+ }
352
+
353
+ /** Bundle the core-js feature modules into one lowered file in `outdir`.
354
+ * Returns its outdir-relative path. */
355
+ async function buildPolyfills(opts: {
356
+ fs: any;
357
+ path: any;
358
+ cwd: string;
359
+ outdir: string;
360
+ features: string[];
361
+ swc: any;
362
+ env: Record<string, unknown>;
363
+ hasher: (s: string) => string;
364
+ salt: string;
365
+ }): Promise<string> {
366
+ const { fs, path, cwd, outdir, features, swc, env, hasher, salt } = opts;
367
+ const stageDir = path.join(cwd, ".pylon");
368
+ fs.mkdirSync(stageDir, { recursive: true });
369
+ const entry = path.join(stageDir, "polyfills-entry.js");
370
+ fs.writeFileSync(
371
+ entry,
372
+ features.map((f) => `import ${JSON.stringify(f)};`).join("\n") + "\n",
373
+ "utf8",
374
+ );
375
+ const tmpOut = path.join(stageDir, "polyfills-build");
376
+ fs.rmSync(tmpOut, { recursive: true, force: true });
377
+ const result = await Bun.build({
378
+ entrypoints: [entry],
379
+ outdir: tmpOut,
380
+ root: cwd,
381
+ target: "browser",
382
+ format: "esm",
383
+ minify: true,
384
+ });
385
+ if (!result.success) {
386
+ const msgs = (result.logs ?? []).map((l: any) => l.message).join("\n");
387
+ throw new Error(`polyfill bundle failed:\n${msgs}`);
388
+ }
389
+ const built = result.outputs.find((o: any) => o.path.endsWith(".js"));
390
+ const code = fs.readFileSync(built.path, "utf8");
391
+ const out = await swc.transform(code, {
392
+ filename: "polyfills.js",
393
+ isModule: true,
394
+ jsc: {
395
+ parser: { syntax: "ecmascript" },
396
+ minify: { compress: true, mangle: true },
397
+ },
398
+ env,
399
+ module: { type: "es6" },
400
+ minify: true,
401
+ });
402
+ const name = `polyfills-${hasher(`${out.code}:${salt}`).slice(0, 10)}.js`;
403
+ fs.writeFileSync(path.join(outdir, name), out.code, "utf8");
404
+ fs.rmSync(tmpOut, { recursive: true, force: true });
405
+ fs.rmSync(entry, { force: true });
406
+ return name;
407
+ }
408
+
409
+ /**
410
+ * Apply Lightning CSS to compiled CSS for the CSS targets: vendor prefixes,
411
+ * nesting and color-syntax lowering, minification.
412
+ */
413
+ export async function transformCss(
414
+ cwd: string,
415
+ css: string,
416
+ filename: string,
417
+ cssTargets: string[],
418
+ ): Promise<string> {
419
+ const why = "build.css.target (or build.target)";
420
+ const lightningcss = await loadAppTool(cwd, "lightningcss", why);
421
+ const browserslist = await loadAppTool(cwd, "browserslist", why);
422
+ const targets = lightningcss.browserslistToTargets(browserslist(cssTargets));
423
+ const out = lightningcss.transform({
424
+ filename,
425
+ code: Buffer.from(css),
426
+ minify: true,
427
+ targets,
428
+ });
429
+ return out.code.toString();
430
+ }
package/src/index.ts CHANGED
@@ -73,6 +73,7 @@ export type {
73
73
  InferArgs,
74
74
  RequireMember,
75
75
  RequireMemberOptions,
76
+ Shards,
76
77
  MemberRow,
77
78
  Workflows,
78
79
  VectorSearchQuery,