@astroscope/airlock 0.1.0 → 0.1.1

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 (3) hide show
  1. package/README.md +5 -3
  2. package/dist/index.js +64 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @astroscope/airlock
2
2
 
3
+ > **Note:** This package is in active development. APIs may change between versions.
4
+
3
5
  Strip excess props from hydrated Astro islands — prevents server data leaking to the client, reduces HTML payload size.
4
6
 
5
7
  ## Why?
@@ -100,15 +102,15 @@ Currently supports **React / Preact** components (`.tsx`, `.ts`, `.jsx`, `.js`).
100
102
 
101
103
  The architecture uses a pluggable adapter pattern — Vue and Svelte adapters can be added without changing the core.
102
104
 
103
- ## Debug logging
105
+ ## Logging
104
106
 
105
- In Astro's verbose mode, airlock logs per-component status:
107
+ Airlock always logs a summary during build:
106
108
 
107
109
  ```
108
110
  [@astroscope/airlock] transformed 3 of 4 hydrated component usage(s)
109
111
  ```
110
112
 
111
- Components that aren't transformed (ALLOW_ALL types, no user props) are logged at debug level with the reason.
113
+ Per-component details (ALLOW_ALL types, unresolved imports, etc.) are logged at debug level — visible with `--verbose`.
112
114
 
113
115
  ## License
114
116
 
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/vite-plugin.ts
2
2
  import fs from "fs/promises";
3
+ import path3 from "path";
3
4
 
4
5
  // src/adapters/react.ts
5
6
  import path from "path";
@@ -8,34 +9,47 @@ import ts2 from "typescript";
8
9
  // src/extractor.ts
9
10
  import ts from "typescript";
10
11
  function generateZodSchema(checker, propsType) {
11
- const ctx = { visiting: /* @__PURE__ */ new Set(), types: /* @__PURE__ */ new Map(), counter: 0 };
12
+ const ctx = { visiting: /* @__PURE__ */ new Set(), types: /* @__PURE__ */ new Map(), counter: 0, depth: 0 };
12
13
  const root = toZod(checker, propsType, ctx);
13
14
  if (root === null) return null;
14
15
  return {
15
16
  root,
16
- types: [...ctx.types.values()].map((t) => `const ${t.name}: z.ZodType<any> = z.lazy(() => ${t.code});`)
17
+ types: [...ctx.types.values()].map((t) => `const ${t.name} = z.lazy(() => ${t.code});`)
17
18
  };
18
19
  }
19
20
  function toZod(checker, type, ctx) {
21
+ ctx.depth++;
22
+ if (ctx.depth > 50) {
23
+ ctx.depth--;
24
+ return "z.any()";
25
+ }
20
26
  if (ctx.visiting.has(type)) return getOrCreateLazyRef(checker, type, ctx);
21
27
  const unwrapped = unwrapOptional(type);
28
+ if (ctx.visiting.has(unwrapped)) return getOrCreateLazyRef(checker, unwrapped, ctx);
22
29
  if (isLeaf(unwrapped)) return null;
23
30
  if (unwrapped.isUnion() && unwrapped.types.every((t) => isLeaf(t))) return null;
24
- if (checker.isArrayType(type) || checker.isArrayType(unwrapped)) {
25
- return arrayToZod(checker, checker.isArrayType(type) ? type : unwrapped, ctx);
26
- }
27
- if (hasIndexSignature(checker, unwrapped)) return null;
28
- if (unwrapped.isUnion()) {
29
- const discriminant = findDiscriminant(checker, unwrapped);
30
- if (discriminant) return discriminatedUnionToZod(checker, unwrapped, discriminant, ctx);
31
+ ctx.visiting.add(type);
32
+ ctx.visiting.add(unwrapped);
33
+ try {
34
+ if (checker.isArrayType(type) || checker.isArrayType(unwrapped)) {
35
+ return arrayToZod(checker, checker.isArrayType(type) ? type : unwrapped, ctx);
36
+ }
37
+ if (hasIndexSignature(checker, unwrapped)) return null;
38
+ if (unwrapped.isUnion()) {
39
+ const discriminant = findDiscriminant(checker, unwrapped);
40
+ if (discriminant) return discriminatedUnionToZod(checker, unwrapped, discriminant, ctx);
41
+ }
42
+ const properties = collectProperties(checker, unwrapped);
43
+ if (!properties) return null;
44
+ return propsToZodObject(checker, properties, ctx);
45
+ } finally {
46
+ ctx.visiting.delete(type);
47
+ ctx.visiting.delete(unwrapped);
48
+ ctx.depth--;
31
49
  }
32
- const properties = collectProperties(checker, unwrapped);
33
- if (!properties) return null;
34
- return propsToZodObject(checker, properties, type, ctx);
35
50
  }
36
- function propsToZodObject(checker, properties, type, ctx) {
51
+ function propsToZodObject(checker, properties, ctx) {
37
52
  if (properties.size === 0) return "z.object({})";
38
- ctx.visiting.add(type);
39
53
  const fields = [];
40
54
  for (const [name, prop] of properties) {
41
55
  const propType = checker.getTypeOfSymbol(prop);
@@ -45,7 +59,6 @@ function propsToZodObject(checker, properties, type, ctx) {
45
59
  }
46
60
  fields.push(`${safeKey(name)}: ${toZod(checker, propType, ctx) ?? "z.any()"}`);
47
61
  }
48
- ctx.visiting.delete(type);
49
62
  return `z.object({${fields.join(", ")}})`;
50
63
  }
51
64
  function collectProperties(checker, type) {
@@ -421,23 +434,24 @@ async function transformCompiledOutput(code, schemas) {
421
434
  const babel = await import("@babel/core");
422
435
  const schemaMap = /* @__PURE__ */ new Map();
423
436
  for (const m of schemas) {
424
- schemaMap.set(stripExt(m.componentPath), m.schemaId);
437
+ schemaMap.set(m.specifier, m.schemaId);
438
+ schemaMap.set(stripExt(m.resolvedPath), m.schemaId);
425
439
  }
426
440
  const matched = /* @__PURE__ */ new Set();
427
441
  const usedSchemaIds = /* @__PURE__ */ new Set();
428
442
  let renderComponentFound = false;
429
443
  const plugin = ({ types: t }) => ({
430
444
  visitor: {
431
- ImportDeclaration(path3) {
432
- if (!path3.node.source.value.includes("compiler-runtime")) return;
433
- for (const specifier of path3.node.specifiers) {
445
+ ImportDeclaration(path4) {
446
+ if (!path4.node.source.value.includes("compiler-runtime")) return;
447
+ for (const specifier of path4.node.specifiers) {
434
448
  if (specifier.type !== "ImportSpecifier") continue;
435
449
  const imported = specifier.imported;
436
450
  const importedName = "name" in imported ? imported.name : imported.value;
437
451
  if (importedName !== "renderComponent") continue;
438
452
  renderComponentFound = true;
439
453
  const localName = specifier.local.name;
440
- const binding = path3.scope.getBinding(localName);
454
+ const binding = path4.scope.getBinding(localName);
441
455
  if (!binding) break;
442
456
  for (const refPath of binding.referencePaths) {
443
457
  const callPath = refPath.parentPath;
@@ -469,19 +483,19 @@ async function transformCompiledOutput(code, schemas) {
469
483
  });
470
484
  if (!renderComponentFound) {
471
485
  throw new Error(
472
- "[@astroscope/airlock] could not find renderComponent import in compiled output. this may indicate a breaking change in Astro's compilation format. airlock refuses to continue to prevent potential data leaks."
486
+ "could not find renderComponent import in compiled output. this may indicate a breaking change in Astro's compilation format. airlock refuses to continue to prevent potential data leaks."
473
487
  );
474
488
  }
475
489
  for (const m of schemas) {
476
- const pathWithoutExt = stripExt(m.componentPath);
477
- if (!matched.has(pathWithoutExt)) {
490
+ if (!matched.has(m.specifier) && !matched.has(stripExt(m.resolvedPath))) {
491
+ const foundPaths = [...matched].join(", ") || "(none)";
478
492
  throw new Error(
479
- `[@astroscope/airlock] component "${pathWithoutExt}" was detected in .astro source but not found in compiled output. airlock refuses to continue to prevent potential data leaks.`
493
+ `component "${m.specifier}" was detected in .astro source but not found in compiled output (matched: ${foundPaths}). airlock refuses to continue to prevent potential data leaks.`
480
494
  );
481
495
  }
482
496
  }
483
497
  if (!result?.code) {
484
- throw new Error("[@astroscope/airlock] babel transform returned no output.");
498
+ throw new Error("babel transform returned no output.");
485
499
  }
486
500
  const imports = [...usedSchemaIds, "__airlock_strip"];
487
501
  const importLine = `import { ${imports.join(", ")} } from '${VIRTUAL_MODULE_ID}';`;
@@ -511,16 +525,35 @@ function airlockVitePlugin(options) {
511
525
  let totalTransformed = 0;
512
526
  let registry;
513
527
  let server;
528
+ let root;
529
+ let isBuild = false;
514
530
  return {
515
531
  name: "@astroscope/airlock",
516
532
  // note: no applyToEnvironment — handleHotUpdate must fire for all environments
517
533
  // the transform hook checks for .astro files, so client env is skipped naturally
518
534
  configResolved(config) {
519
- registry = new SchemaRegistry([new ReactAdapter(config.root, logger)]);
535
+ root = config.root;
536
+ isBuild = config.command === "build";
537
+ registry = new SchemaRegistry([new ReactAdapter(root, logger)]);
520
538
  },
521
539
  configureServer(devServer) {
522
540
  server = devServer;
523
541
  },
542
+ async buildStart() {
543
+ if (!isBuild) return;
544
+ const srcDir = path3.join(root, "src");
545
+ const entries = await fs.readdir(srcDir, { recursive: true, withFileTypes: true });
546
+ for (const entry of entries) {
547
+ if (!entry.isFile() || !entry.name.endsWith(".astro")) continue;
548
+ const filePath = path3.join(entry.parentPath, entry.name);
549
+ const raw = await fs.readFile(filePath, "utf-8");
550
+ const analyzed = await analyzeAstroSource(raw);
551
+ for (const comp of analyzed.hydratedComponents) {
552
+ if (!comp.importInfo) continue;
553
+ registry.resolve(comp.importInfo.specifier, comp.importInfo.exportName, filePath);
554
+ }
555
+ }
556
+ },
524
557
  resolveId(id) {
525
558
  if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
526
559
  },
@@ -555,7 +588,11 @@ function airlockVitePlugin(options) {
555
588
  logger.debug(`<${comp.name}> (${comp.importInfo.specifier}) \u2014 ALLOW_ALL`);
556
589
  continue;
557
590
  }
558
- componentsToWrap.push({ componentPath: resolved.resolvedPath, schemaId: resolved.schemaId });
591
+ componentsToWrap.push({
592
+ specifier: comp.importInfo.specifier,
593
+ resolvedPath: resolved.resolvedPath,
594
+ schemaId: resolved.schemaId
595
+ });
559
596
  }
560
597
  if (componentsToWrap.length === 0) return;
561
598
  if (server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astroscope/airlock",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Strip excess props from hydrated Astro islands to prevent server data leakage",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",