@astroscope/airlock 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Simon Bobrov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @astroscope/airlock
2
+
3
+ Strip excess props from hydrated Astro islands — prevents server data leaking to the client, reduces HTML payload size.
4
+
5
+ ## Why?
6
+
7
+ When Astro hydrates a framework component with `client:*` directives, **all props are serialized** into the HTML. TypeScript's structural typing allows passing objects with extra properties — those silently leak to the client.
8
+
9
+ ```astro
10
+ ---
11
+ const user = await db.getUser(id);
12
+ // user = { name, email, passwordHash, sessionToken, ... }
13
+ ---
14
+
15
+ <!-- passwordHash and sessionToken end up in the page source -->
16
+ <UserCard client:load {...user} />
17
+ ```
18
+
19
+ Airlock uses the component's TypeScript prop types to strip unknown keys before serialization. If `UserCard` declares `{ name: string; email: string }`, only those fields reach the client.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @astroscope/airlock
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ts
30
+ // astro.config.ts
31
+ import { defineConfig } from 'astro/config';
32
+ import airlock from '@astroscope/airlock';
33
+ import react from '@astrojs/react';
34
+
35
+ export default defineConfig({
36
+ integrations: [react(), airlock()],
37
+ });
38
+ ```
39
+
40
+ That's it. No changes to your components or templates.
41
+
42
+ ## Important disclaimer
43
+
44
+ Airlock is **not a silver bullet** — it is an additional layer of defense. Passing sensitive server data to client components is still a bad practice. The best approach is to explicitly map only the fields you need. Airlock catches the cases where that discipline slips.
45
+
46
+ Example: a component that was correctly stripped can silently break if someone changes its props type to `any` or `Record<string, unknown>` — airlock sees "allow all" and stops stripping. A refactor that widens a type can undo the protection without any visible error. Airlock catches accidental leaks, but it can't protect against type definitions that explicitly opt out of safety.
47
+
48
+ If hydrated components in your `.astro` source are detected but cannot be matched in the compiled output, **the build will fail**. This is intentional — breaking the build is safer than silently leaking data.
49
+
50
+ ## How it works
51
+
52
+ Under the hood, airlock generates [Zod](https://zod.dev) schemas from your component's TypeScript types and uses `.parse()` to strip unknown keys at the serialization boundary.
53
+
54
+ 1. **Detects hydrated components** — parses raw `.astro` source using `@astrojs/compiler` to find components with `client:*` directives
55
+ 2. **Extracts prop types** — uses the TypeScript compiler API to resolve the component's declared prop types
56
+ 3. **Generates Zod schemas** — converts prop types to Zod schema code (e.g. `z.object({ name: z.any(), email: z.any() })`)
57
+ 4. **Wraps props in compiled output** — uses Babel to find `renderComponent` calls via scope analysis and wraps their props argument with `schema.parse()`
58
+ 5. **Schemas are shared** — a virtual module holds all schemas, so each is created once at runtime regardless of how many pages use the component
59
+ 6. **Verifies completeness** — every detected component must be found in the compiled output, otherwise the build fails
60
+
61
+ ### Before (what Astro serializes)
62
+
63
+ ```html
64
+ <astro-island
65
+ props='{"name":"John","email":"j@test.com","passwordHash":"$2b$10$...","sessionToken":"eyJ..."}'
66
+ ></astro-island>
67
+ ```
68
+
69
+ ### After (with airlock)
70
+
71
+ ```html
72
+ <astro-island props='{"name":"John","email":"j@test.com"}'></astro-island>
73
+ ```
74
+
75
+ ## What it handles
76
+
77
+ - **Flat objects** — strips top-level excess keys
78
+ - **Nested objects** — recursively strips at every level
79
+ - **Arrays of objects** — strips excess keys from each element
80
+ - **Discriminated unions** — uses `z.discriminatedUnion()` for precise per-variant stripping
81
+ - **Recursive types** — handles self-referencing types via `z.lazy()`
82
+ - **Generic components** — extracts prop shapes from type parameter constraints
83
+ - **All import styles** — default, named, aliased (`import { Card as UserCard }`)
84
+ - **Record / index signatures** — treated as ALLOW_ALL (no stripping)
85
+
86
+ ### When stripping is skipped
87
+
88
+ Airlock skips stripping entirely for components whose props type intentionally accepts arbitrary keys:
89
+
90
+ - **Entire props type** is `any`, `unknown`, or `Record<string, unknown>` — no type info to strip with
91
+ - Components with no user props (only `client:*` directives)
92
+
93
+ Note: individual properties typed as `any` are still kept — only their **keys** matter for stripping. `{ data: any; title: string }` keeps both `data` and `title`, but strips any other key. The `data` contents will be passed as-is.
94
+
95
+ These components pass all props through unchanged.
96
+
97
+ ## Framework support
98
+
99
+ Currently supports **React / Preact** components (`.tsx`, `.ts`, `.jsx`, `.js`).
100
+
101
+ The architecture uses a pluggable adapter pattern — Vue and Svelte adapters can be added without changing the core.
102
+
103
+ ## Debug logging
104
+
105
+ In Astro's verbose mode, airlock logs per-component status:
106
+
107
+ ```
108
+ [@astroscope/airlock] transformed 3 of 4 hydrated component usage(s)
109
+ ```
110
+
111
+ Components that aren't transformed (ALLOW_ALL types, no user props) are logged at debug level with the reason.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,12 @@
1
+ import { AstroIntegration } from 'astro';
2
+
3
+ interface AirlockOptions {
4
+ }
5
+
6
+ /**
7
+ * astro integration that strips excess props from hydrated islands,
8
+ * preventing accidental server data leakage to the client.
9
+ */
10
+ declare function airlock(_options?: AirlockOptions): AstroIntegration;
11
+
12
+ export { type AirlockOptions, airlock as default };
package/dist/index.js ADDED
@@ -0,0 +1,607 @@
1
+ // src/vite-plugin.ts
2
+ import fs from "fs/promises";
3
+
4
+ // src/adapters/react.ts
5
+ import path from "path";
6
+ import ts2 from "typescript";
7
+
8
+ // src/extractor.ts
9
+ import ts from "typescript";
10
+ function generateZodSchema(checker, propsType) {
11
+ const ctx = { visiting: /* @__PURE__ */ new Set(), types: /* @__PURE__ */ new Map(), counter: 0 };
12
+ const root = toZod(checker, propsType, ctx);
13
+ if (root === null) return null;
14
+ return {
15
+ root,
16
+ types: [...ctx.types.values()].map((t) => `const ${t.name}: z.ZodType<any> = z.lazy(() => ${t.code});`)
17
+ };
18
+ }
19
+ function toZod(checker, type, ctx) {
20
+ if (ctx.visiting.has(type)) return getOrCreateLazyRef(checker, type, ctx);
21
+ const unwrapped = unwrapOptional(type);
22
+ if (isLeaf(unwrapped)) return null;
23
+ 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
+ }
32
+ const properties = collectProperties(checker, unwrapped);
33
+ if (!properties) return null;
34
+ return propsToZodObject(checker, properties, type, ctx);
35
+ }
36
+ function propsToZodObject(checker, properties, type, ctx) {
37
+ if (properties.size === 0) return "z.object({})";
38
+ ctx.visiting.add(type);
39
+ const fields = [];
40
+ for (const [name, prop] of properties) {
41
+ const propType = checker.getTypeOfSymbol(prop);
42
+ if (propType.getCallSignatures().length > 0 && !propType.getProperties().length) {
43
+ fields.push(`${safeKey(name)}: z.any()`);
44
+ continue;
45
+ }
46
+ fields.push(`${safeKey(name)}: ${toZod(checker, propType, ctx) ?? "z.any()"}`);
47
+ }
48
+ ctx.visiting.delete(type);
49
+ return `z.object({${fields.join(", ")}})`;
50
+ }
51
+ function collectProperties(checker, type) {
52
+ const result = /* @__PURE__ */ new Map();
53
+ if (type.isUnion() || type.isIntersection()) {
54
+ for (const member of type.types) {
55
+ if (hasIndexSignature(checker, member)) return null;
56
+ for (const prop of member.getProperties()) {
57
+ result.set(prop.name, prop);
58
+ }
59
+ }
60
+ } else {
61
+ for (const prop of type.getProperties()) {
62
+ result.set(prop.name, prop);
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+ function arrayToZod(checker, arrayType, ctx) {
68
+ const typeArgs = arrayType.typeArguments;
69
+ if (!typeArgs?.length) return "z.any()";
70
+ const elementZod = toZod(checker, typeArgs[0], ctx) ?? "z.any()";
71
+ return `z.array(${elementZod})`;
72
+ }
73
+ function discriminatedUnionToZod(checker, union, discriminant, ctx) {
74
+ const variants = [];
75
+ for (const member of union.types) {
76
+ if (!isObjectLike(member)) continue;
77
+ const fields = [];
78
+ for (const prop of member.getProperties()) {
79
+ const propType = checker.getTypeOfSymbol(prop);
80
+ if (prop.name === discriminant && isLiteralType(propType)) {
81
+ fields.push(`${safeKey(prop.name)}: z.literal(${getLiteralValue(propType)})`);
82
+ } else {
83
+ fields.push(`${safeKey(prop.name)}: ${toZod(checker, propType, ctx) ?? "z.any()"}`);
84
+ }
85
+ }
86
+ variants.push(`z.object({${fields.join(", ")}})`);
87
+ }
88
+ return `z.discriminatedUnion(${JSON.stringify(discriminant)}, [${variants.join(", ")}])`;
89
+ }
90
+ function getOrCreateLazyRef(checker, type, ctx) {
91
+ const existing = ctx.types.get(type);
92
+ if (existing) return existing.name;
93
+ const name = `__zr${ctx.counter++}`;
94
+ ctx.types.set(type, { name, code: "" });
95
+ ctx.visiting.delete(type);
96
+ const code = toZod(checker, type, ctx) ?? "z.any()";
97
+ ctx.types.set(type, { name, code });
98
+ return name;
99
+ }
100
+ function findDiscriminant(checker, union) {
101
+ if (union.types.length < 2) return null;
102
+ const firstMember = union.types[0];
103
+ for (const prop of firstMember.getProperties()) {
104
+ if (!isLiteralType(checker.getTypeOfSymbol(prop))) continue;
105
+ const allHaveLiteral = union.types.every((member) => {
106
+ const memberProp = member.getProperty(prop.name);
107
+ return memberProp ? isLiteralType(checker.getTypeOfSymbol(memberProp)) : false;
108
+ });
109
+ if (allHaveLiteral) return prop.name;
110
+ }
111
+ return null;
112
+ }
113
+ function isLeaf(type) {
114
+ return isPrimitive(type) || !!(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown));
115
+ }
116
+ function isPrimitive(type) {
117
+ const flags = (
118
+ // serializable primitives
119
+ ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.EnumLiteral | // not serializable but allowed — astro will error on these before airlock matters
120
+ ts.TypeFlags.Void | ts.TypeFlags.BigInt | ts.TypeFlags.BigIntLiteral | ts.TypeFlags.ESSymbol | ts.TypeFlags.Never
121
+ );
122
+ return (type.flags & flags) !== 0;
123
+ }
124
+ function isObjectLike(type) {
125
+ return !isLeaf(type);
126
+ }
127
+ function isLiteralType(type) {
128
+ return !!(type.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral));
129
+ }
130
+ function getLiteralValue(type) {
131
+ if (type.isStringLiteral()) return JSON.stringify(type.value);
132
+ if (type.isNumberLiteral()) return String(type.value);
133
+ if (type.flags & ts.TypeFlags.BooleanLiteral) {
134
+ return type.intrinsicName === "true" ? "true" : "false";
135
+ }
136
+ return JSON.stringify(String(type));
137
+ }
138
+ function hasIndexSignature(checker, type) {
139
+ try {
140
+ return checker.getIndexTypeOfType(type, ts.IndexKind.String) !== void 0;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ function unwrapOptional(type) {
146
+ if (!type.isUnion()) return type;
147
+ const filtered = type.types.filter((t) => !(t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)));
148
+ if (filtered.length === 1) return filtered[0];
149
+ return type;
150
+ }
151
+ function safeKey(name) {
152
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
153
+ }
154
+
155
+ // src/adapters/react.ts
156
+ var ReactAdapter = class {
157
+ name = "react";
158
+ extensions = [".tsx", ".ts", ".jsx", ".js"];
159
+ program = null;
160
+ projectRoot;
161
+ logger;
162
+ constructor(projectRoot, logger) {
163
+ this.projectRoot = projectRoot;
164
+ this.logger = logger;
165
+ }
166
+ canHandle(filePath) {
167
+ return this.extensions.some((ext) => filePath.endsWith(ext));
168
+ }
169
+ extractSchema(filePath, exportName) {
170
+ const prog = this.getProgram();
171
+ const checker = prog.getTypeChecker();
172
+ const sourceFile = prog.getSourceFile(filePath);
173
+ if (!sourceFile) {
174
+ this.logger.warn(`resolved ${filePath} but failed to load source file`);
175
+ return void 0;
176
+ }
177
+ const propsType = this.resolvePropsType(checker, sourceFile, exportName);
178
+ if (!propsType) return null;
179
+ return generateZodSchema(checker, propsType);
180
+ }
181
+ invalidate(_filePath) {
182
+ this.program = null;
183
+ }
184
+ resolveModulePath(importSpecifier, fromFile) {
185
+ const prog = this.getProgram();
186
+ const resolved = ts2.resolveModuleName(importSpecifier, fromFile, prog.getCompilerOptions(), ts2.sys);
187
+ if (!resolved.resolvedModule) {
188
+ this.logger.warn(`could not resolve '${importSpecifier}' from ${fromFile}`);
189
+ return void 0;
190
+ }
191
+ return resolved.resolvedModule.resolvedFileName;
192
+ }
193
+ /**
194
+ * resolve the props type for a React component export.
195
+ * handles function declarations, arrow functions, React.FC<Props>, etc.
196
+ */
197
+ resolvePropsType(checker, sourceFile, exportName) {
198
+ const symbol = this.getExportSymbol(checker, sourceFile, exportName);
199
+ if (!symbol) return void 0;
200
+ const symbolType = checker.getTypeOfSymbol(symbol);
201
+ const callSignatures = symbolType.getCallSignatures();
202
+ if (callSignatures.length === 0) return void 0;
203
+ const params = callSignatures[0].getParameters();
204
+ if (params.length === 0) return void 0;
205
+ return checker.getTypeOfSymbol(params[0]);
206
+ }
207
+ getExportSymbol(checker, sourceFile, exportName) {
208
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
209
+ if (!moduleSymbol) return void 0;
210
+ const exports = checker.getExportsOfModule(moduleSymbol);
211
+ const target = exportName === "default" ? "default" : exportName;
212
+ const exportSymbol = exports.find((s) => s.escapedName === target);
213
+ if (!exportSymbol) return void 0;
214
+ return this.resolveAlias(checker, exportSymbol);
215
+ }
216
+ resolveAlias(checker, symbol) {
217
+ if (symbol.flags & ts2.SymbolFlags.Alias) {
218
+ return this.resolveAlias(checker, checker.getAliasedSymbol(symbol));
219
+ }
220
+ return symbol;
221
+ }
222
+ getProgram() {
223
+ if (this.program) return this.program;
224
+ const tsconfigPath = path.join(this.projectRoot, "tsconfig.json");
225
+ const configFile = ts2.readConfigFile(tsconfigPath, ts2.sys.readFile);
226
+ const parsedConfig = ts2.parseJsonConfigFileContent(configFile.config, ts2.sys, this.projectRoot);
227
+ this.program = ts2.createProgram(parsedConfig.fileNames, parsedConfig.options);
228
+ return this.program;
229
+ }
230
+ };
231
+
232
+ // src/astro-analyze.ts
233
+ import { parse } from "@astrojs/compiler";
234
+ import ts3 from "typescript";
235
+ async function analyzeAstroSource(raw) {
236
+ const { ast } = await parse(raw);
237
+ const frontmatterNode = ast.children.find((n) => n.type === "frontmatter");
238
+ const frontmatter = frontmatterNode?.value ?? "";
239
+ const imports = resolveImports(frontmatter);
240
+ const hydratedComponents = findHydratedComponents(ast.children, imports);
241
+ return { imports, hydratedComponents };
242
+ }
243
+ function resolveImports(frontmatter) {
244
+ const imports = /* @__PURE__ */ new Map();
245
+ if (!frontmatter) return imports;
246
+ const sourceFile = ts3.createSourceFile(
247
+ "__airlock__.ts",
248
+ frontmatter,
249
+ ts3.ScriptTarget.Latest,
250
+ false,
251
+ ts3.ScriptKind.TS
252
+ );
253
+ for (const stmt of sourceFile.statements) {
254
+ if (!ts3.isImportDeclaration(stmt)) continue;
255
+ if (!stmt.moduleSpecifier || !ts3.isStringLiteral(stmt.moduleSpecifier)) continue;
256
+ const specifier = stmt.moduleSpecifier.text;
257
+ const clause = stmt.importClause;
258
+ if (!clause) continue;
259
+ if (clause.name) {
260
+ imports.set(clause.name.text, { specifier, exportName: "default" });
261
+ }
262
+ if (clause.namedBindings && ts3.isNamedImports(clause.namedBindings)) {
263
+ for (const element of clause.namedBindings.elements) {
264
+ const localName = element.name.text;
265
+ const exportName = element.propertyName?.text ?? element.name.text;
266
+ imports.set(localName, { specifier, exportName });
267
+ }
268
+ }
269
+ if (clause.namedBindings && ts3.isNamespaceImport(clause.namedBindings)) {
270
+ imports.set(clause.namedBindings.name.text, { specifier, exportName: "*" });
271
+ }
272
+ }
273
+ return imports;
274
+ }
275
+ function findHydratedComponents(nodes, imports) {
276
+ const components = [];
277
+ function process(nodes2, visitor) {
278
+ for (const node of nodes2) {
279
+ visitor(node);
280
+ if ("children" in node && Array.isArray(node.children)) {
281
+ process(node.children, visitor);
282
+ }
283
+ }
284
+ }
285
+ process(nodes, (node) => {
286
+ if (node.type !== "component") return;
287
+ const comp = node;
288
+ if (!comp.attributes.some((a) => a.type === "attribute" && a.name.startsWith("client:"))) return;
289
+ components.push({
290
+ name: comp.name,
291
+ importInfo: imports.get(comp.name)
292
+ });
293
+ });
294
+ return components;
295
+ }
296
+
297
+ // src/dep-tracker.ts
298
+ var DepTracker = class {
299
+ astroToDeps = /* @__PURE__ */ new Map();
300
+ depToAstros = /* @__PURE__ */ new Map();
301
+ /**
302
+ * record that an .astro file depends on a resolved component path.
303
+ */
304
+ track(astroFile, resolvedComponentPath) {
305
+ let deps = this.astroToDeps.get(astroFile);
306
+ if (!deps) {
307
+ deps = /* @__PURE__ */ new Set();
308
+ this.astroToDeps.set(astroFile, deps);
309
+ }
310
+ deps.add(resolvedComponentPath);
311
+ let astros = this.depToAstros.get(resolvedComponentPath);
312
+ if (!astros) {
313
+ astros = /* @__PURE__ */ new Set();
314
+ this.depToAstros.set(resolvedComponentPath, astros);
315
+ }
316
+ astros.add(astroFile);
317
+ }
318
+ /**
319
+ * clear all dependencies for an .astro file (before re-tracking).
320
+ */
321
+ clear(astroFile) {
322
+ const deps = this.astroToDeps.get(astroFile);
323
+ if (!deps) return;
324
+ for (const dep of deps) {
325
+ this.depToAstros.get(dep)?.delete(astroFile);
326
+ }
327
+ this.astroToDeps.delete(astroFile);
328
+ }
329
+ /**
330
+ * get all .astro files that depend on a component path.
331
+ */
332
+ getDependents(resolvedComponentPath) {
333
+ return this.depToAstros.get(resolvedComponentPath);
334
+ }
335
+ };
336
+
337
+ // src/schema-registry.ts
338
+ var VIRTUAL_MODULE_ID = "virtual:@astroscope/airlock/schemas";
339
+ var RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
340
+ var SchemaRegistry = class {
341
+ schemas = /* @__PURE__ */ new Map();
342
+ adapters;
343
+ idCounter = 0;
344
+ constructor(adapters) {
345
+ this.adapters = adapters;
346
+ }
347
+ /**
348
+ * resolve an import specifier to a schema.
349
+ * registers the schema in the registry for virtual module emission.
350
+ */
351
+ resolve(importSpecifier, exportName, fromFile) {
352
+ for (const adapter of this.adapters) {
353
+ const filePath = adapter.resolveModulePath(importSpecifier, fromFile);
354
+ if (!filePath) continue;
355
+ if (!adapter.canHandle(filePath)) continue;
356
+ const cacheKey = `${filePath}#${exportName}`;
357
+ if (this.schemas.has(cacheKey)) return this.schemas.get(cacheKey);
358
+ const schema = adapter.extractSchema(filePath, exportName);
359
+ if (schema === void 0) continue;
360
+ const schemaId = `__s${this.idCounter++}`;
361
+ const result = { schema, resolvedPath: filePath, schemaId };
362
+ this.schemas.set(cacheKey, result);
363
+ return result;
364
+ }
365
+ return void 0;
366
+ }
367
+ /**
368
+ * generate the virtual module source code.
369
+ * each schema is exported as a named constant, created once.
370
+ */
371
+ generateVirtualModule() {
372
+ const lines = ["import { z } from 'astro/zod';"];
373
+ for (const entry of this.schemas.values()) {
374
+ if (entry.schema === null) continue;
375
+ const body = [...entry.schema.types, `return ${entry.schema.root};`].join("\n ");
376
+ lines.push(`export const ${entry.schemaId} = (() => {
377
+ ${body}
378
+ })();`);
379
+ }
380
+ lines.push(
381
+ "export function __airlock_strip(schema, props) {",
382
+ " const clean = schema.parse(props);",
383
+ ' for (const k of Object.keys(props)) if (k.startsWith("client:")) clean[k] = props[k];',
384
+ " return clean;",
385
+ "}"
386
+ );
387
+ return lines.join("\n");
388
+ }
389
+ /**
390
+ * invalidate cached schemas for a changed file.
391
+ */
392
+ invalidate(filePath) {
393
+ for (const adapter of this.adapters) {
394
+ if (adapter.canHandle(filePath)) {
395
+ adapter.invalidate(filePath);
396
+ }
397
+ }
398
+ for (const [key, value] of this.schemas) {
399
+ if (value.resolvedPath === filePath) {
400
+ this.schemas.delete(key);
401
+ }
402
+ }
403
+ }
404
+ /**
405
+ * check whether any adapter handles this file extension.
406
+ */
407
+ canHandle(filePath) {
408
+ return this.adapters.some((a) => a.canHandle(filePath));
409
+ }
410
+ };
411
+
412
+ // src/utils.ts
413
+ import path2 from "path";
414
+ function stripExt(filePath) {
415
+ const { dir, name } = path2.parse(filePath);
416
+ return path2.join(dir, name);
417
+ }
418
+
419
+ // src/transform.ts
420
+ async function transformCompiledOutput(code, schemas) {
421
+ const babel = await import("@babel/core");
422
+ const schemaMap = /* @__PURE__ */ new Map();
423
+ for (const m of schemas) {
424
+ schemaMap.set(stripExt(m.componentPath), m.schemaId);
425
+ }
426
+ const matched = /* @__PURE__ */ new Set();
427
+ const usedSchemaIds = /* @__PURE__ */ new Set();
428
+ let renderComponentFound = false;
429
+ const plugin = ({ types: t }) => ({
430
+ visitor: {
431
+ ImportDeclaration(path3) {
432
+ if (!path3.node.source.value.includes("compiler-runtime")) return;
433
+ for (const specifier of path3.node.specifiers) {
434
+ if (specifier.type !== "ImportSpecifier") continue;
435
+ const imported = specifier.imported;
436
+ const importedName = "name" in imported ? imported.name : imported.value;
437
+ if (importedName !== "renderComponent") continue;
438
+ renderComponentFound = true;
439
+ const localName = specifier.local.name;
440
+ const binding = path3.scope.getBinding(localName);
441
+ if (!binding) break;
442
+ for (const refPath of binding.referencePaths) {
443
+ const callPath = refPath.parentPath;
444
+ if (!callPath?.isCallExpression()) continue;
445
+ const propsArg = callPath.node.arguments[3];
446
+ if (!propsArg || !t.isObjectExpression(propsArg)) continue;
447
+ const componentPath = getStringProp(t, propsArg);
448
+ if (!componentPath) continue;
449
+ const schemaId = schemaMap.get(componentPath);
450
+ if (!schemaId) continue;
451
+ matched.add(componentPath);
452
+ usedSchemaIds.add(schemaId);
453
+ callPath.node.arguments[3] = t.callExpression(t.identifier("__airlock_strip"), [
454
+ t.identifier(schemaId),
455
+ propsArg
456
+ ]);
457
+ }
458
+ break;
459
+ }
460
+ }
461
+ }
462
+ });
463
+ const result = babel.transformSync(code, {
464
+ plugins: [plugin],
465
+ sourceType: "module",
466
+ sourceMaps: true,
467
+ configFile: false,
468
+ babelrc: false
469
+ });
470
+ if (!renderComponentFound) {
471
+ 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."
473
+ );
474
+ }
475
+ for (const m of schemas) {
476
+ const pathWithoutExt = stripExt(m.componentPath);
477
+ if (!matched.has(pathWithoutExt)) {
478
+ 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.`
480
+ );
481
+ }
482
+ }
483
+ if (!result?.code) {
484
+ throw new Error("[@astroscope/airlock] babel transform returned no output.");
485
+ }
486
+ const imports = [...usedSchemaIds, "__airlock_strip"];
487
+ const importLine = `import { ${imports.join(", ")} } from '${VIRTUAL_MODULE_ID}';`;
488
+ return {
489
+ code: `${importLine}
490
+ ${result.code}`,
491
+ map: result.map
492
+ };
493
+ }
494
+ function getStringProp(t, obj) {
495
+ for (const prop of obj.properties) {
496
+ if (!t.isObjectProperty(prop)) continue;
497
+ let propName = null;
498
+ if (t.isIdentifier(prop.key)) propName = prop.key.name;
499
+ else if (t.isStringLiteral(prop.key)) propName = prop.key.value;
500
+ if (propName !== "client:component-path") continue;
501
+ if (t.isStringLiteral(prop.value)) return prop.value.value;
502
+ }
503
+ return null;
504
+ }
505
+
506
+ // src/vite-plugin.ts
507
+ function airlockVitePlugin(options) {
508
+ const { logger } = options;
509
+ const deps = new DepTracker();
510
+ let totalSeen = 0;
511
+ let totalTransformed = 0;
512
+ let registry;
513
+ let server;
514
+ return {
515
+ name: "@astroscope/airlock",
516
+ // note: no applyToEnvironment — handleHotUpdate must fire for all environments
517
+ // the transform hook checks for .astro files, so client env is skipped naturally
518
+ configResolved(config) {
519
+ registry = new SchemaRegistry([new ReactAdapter(config.root, logger)]);
520
+ },
521
+ configureServer(devServer) {
522
+ server = devServer;
523
+ },
524
+ resolveId(id) {
525
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
526
+ },
527
+ load(id) {
528
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
529
+ return registry.generateVirtualModule();
530
+ }
531
+ },
532
+ async transform(code, id) {
533
+ if (!id.endsWith(".astro")) return;
534
+ const raw = await fs.readFile(id, "utf-8");
535
+ if (!raw.includes("client:")) return;
536
+ const analyzed = await analyzeAstroSource(raw);
537
+ if (!analyzed.hydratedComponents.length) return;
538
+ deps.clear(id);
539
+ const componentsToWrap = [];
540
+ for (const comp of analyzed.hydratedComponents) {
541
+ totalSeen++;
542
+ if (!comp.importInfo) {
543
+ logger.warn(`<${comp.name}> \u2014 no matching import`);
544
+ continue;
545
+ }
546
+ const resolved = registry.resolve(comp.importInfo.specifier, comp.importInfo.exportName, id);
547
+ if (!resolved) {
548
+ logger.warn(`<${comp.name}> (${comp.importInfo.specifier}) \u2014 not resolved`);
549
+ continue;
550
+ }
551
+ if (comp.importInfo.specifier) {
552
+ deps.track(id, resolved.resolvedPath);
553
+ }
554
+ if (resolved.schema === null) {
555
+ logger.debug(`<${comp.name}> (${comp.importInfo.specifier}) \u2014 ALLOW_ALL`);
556
+ continue;
557
+ }
558
+ componentsToWrap.push({ componentPath: resolved.resolvedPath, schemaId: resolved.schemaId });
559
+ }
560
+ if (componentsToWrap.length === 0) return;
561
+ if (server) {
562
+ const virtualMod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID);
563
+ if (virtualMod) {
564
+ server.moduleGraph.invalidateModule(virtualMod);
565
+ }
566
+ }
567
+ const result = await transformCompiledOutput(code, componentsToWrap);
568
+ totalTransformed += componentsToWrap.length;
569
+ return result;
570
+ },
571
+ closeBundle() {
572
+ if (!totalSeen && !totalTransformed) return;
573
+ logger.info(`transformed ${totalTransformed} of ${totalSeen} hydrated component usage(s)`);
574
+ },
575
+ handleHotUpdate({ file }) {
576
+ if (!server || !registry.canHandle(file)) return;
577
+ registry.invalidate(file);
578
+ const dependents = deps.getDependents(file);
579
+ if (!dependents) return;
580
+ for (const astroFile of dependents) {
581
+ const mod = server.moduleGraph.getModuleById(astroFile);
582
+ if (mod) {
583
+ server.reloadModule(mod);
584
+ }
585
+ }
586
+ }
587
+ };
588
+ }
589
+
590
+ // src/integration.ts
591
+ function airlock(_options = {}) {
592
+ return {
593
+ name: "@astroscope/airlock",
594
+ hooks: {
595
+ "astro:config:setup": ({ updateConfig, logger }) => {
596
+ updateConfig({
597
+ vite: {
598
+ plugins: [airlockVitePlugin({ logger })]
599
+ }
600
+ });
601
+ }
602
+ }
603
+ };
604
+ }
605
+ export {
606
+ airlock as default
607
+ };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@astroscope/airlock",
3
+ "version": "0.1.0",
4
+ "description": "Strip excess props from hydrated Astro islands to prevent server data leakage",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "sideEffects": false,
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/smnbbrv/astroscope.git",
24
+ "directory": "packages/airlock"
25
+ },
26
+ "keywords": [
27
+ "astro",
28
+ "astro-integration",
29
+ "security",
30
+ "performance",
31
+ "data-leak-prevention",
32
+ "props",
33
+ "islands",
34
+ "serialization"
35
+ ],
36
+ "author": "smnbbrv",
37
+ "license": "MIT",
38
+ "bugs": {
39
+ "url": "https://github.com/smnbbrv/astroscope/issues"
40
+ },
41
+ "homepage": "https://github.com/smnbbrv/astroscope/tree/main/packages/airlock#readme",
42
+ "dependencies": {
43
+ "@astrojs/compiler": "^3.0.0",
44
+ "@babel/core": "^7.29.0"
45
+ },
46
+ "devDependencies": {
47
+ "@babel/types": "^7.29.0",
48
+ "@types/babel__core": "^7.20.5",
49
+ "astro": "^6.0.7",
50
+ "tsup": "^8.5.1",
51
+ "typescript": "^5.9.3",
52
+ "vite": "^7.3.1"
53
+ },
54
+ "peerDependencies": {
55
+ "astro": "^6.0.0",
56
+ "typescript": "^5.0.0",
57
+ "vite": "^7.0.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsup src/index.ts --format esm --dts --external @astrojs/compiler --external @babel/core",
61
+ "typecheck": "tsc --noEmit",
62
+ "lint": "eslint src",
63
+ "lint:fix": "eslint src --fix",
64
+ "preview": "astro preview"
65
+ }
66
+ }