Repository: lightpanda-io/browser Branch: main Commit: 4cdc24326af3 Files: 697 Total size: 4.6 MB Directory structure: gitextract_h4l2nhcz/ ├── .github/ │ ├── actions/ │ │ └── install/ │ │ └── action.yml │ └── workflows/ │ ├── cla.yml │ ├── e2e-integration-test.yml │ ├── e2e-test.yml │ ├── nightly.yml │ ├── wpt.yml │ └── zig-test.yml ├── .gitignore ├── CLA.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── LICENSING.md ├── Makefile ├── README.md ├── build.zig ├── build.zig.zon ├── flake.nix └── src/ ├── App.zig ├── ArenaPool.zig ├── Config.zig ├── Notification.zig ├── SemanticTree.zig ├── Server.zig ├── Sighandler.zig ├── TestHTTPServer.zig ├── browser/ │ ├── Browser.zig │ ├── EventManager.zig │ ├── Factory.zig │ ├── HttpClient.zig │ ├── Mime.zig │ ├── Page.zig │ ├── ScriptManager.zig │ ├── Session.zig │ ├── URL.zig │ ├── actions.zig │ ├── color.zig │ ├── css/ │ │ ├── Parser.zig │ │ └── Tokenizer.zig │ ├── dump.zig │ ├── interactive.zig │ ├── js/ │ │ ├── Array.zig │ │ ├── BigInt.zig │ │ ├── Caller.zig │ │ ├── Context.zig │ │ ├── Env.zig │ │ ├── Function.zig │ │ ├── HandleScope.zig │ │ ├── Inspector.zig │ │ ├── Integer.zig │ │ ├── Isolate.zig │ │ ├── Local.zig │ │ ├── Module.zig │ │ ├── Number.zig │ │ ├── Object.zig │ │ ├── Origin.zig │ │ ├── Platform.zig │ │ ├── Private.zig │ │ ├── Promise.zig │ │ ├── PromiseRejection.zig │ │ ├── PromiseResolver.zig │ │ ├── Scheduler.zig │ │ ├── Snapshot.zig │ │ ├── String.zig │ │ ├── TaggedOpaque.zig │ │ ├── TryCatch.zig │ │ ├── Value.zig │ │ ├── bridge.zig │ │ └── js.zig │ ├── markdown.zig │ ├── parser/ │ │ ├── Parser.zig │ │ └── html5ever.zig │ ├── reflect.zig │ ├── structured_data.zig │ ├── tests/ │ │ ├── animation/ │ │ │ └── animation.html │ │ ├── blob.html │ │ ├── canvas/ │ │ │ ├── canvas_rendering_context_2d.html │ │ │ ├── offscreen_canvas.html │ │ │ └── webgl_rendering_context.html │ │ ├── cdata/ │ │ │ ├── cdata_section.html │ │ │ ├── character_data.html │ │ │ ├── comment.html │ │ │ ├── data.html │ │ │ └── text.html │ │ ├── cdp/ │ │ │ ├── dom1.html │ │ │ ├── dom2.html │ │ │ ├── dom3.html │ │ │ ├── registry1.html │ │ │ ├── registry2.html │ │ │ └── registry3.html │ │ ├── collections/ │ │ │ └── radio_node_list.html │ │ ├── console/ │ │ │ └── console.html │ │ ├── crypto.html │ │ ├── css/ │ │ │ ├── font_face.html │ │ │ ├── font_face_set.html │ │ │ ├── media_query_list.html │ │ │ └── stylesheet.html │ │ ├── css.html │ │ ├── custom_elements/ │ │ │ ├── attribute_changed.html │ │ │ ├── built_in.html │ │ │ ├── connected.html │ │ │ ├── connected_from_parser.html │ │ │ ├── constructor.html │ │ │ ├── disconnected.html │ │ │ ├── registry.html │ │ │ ├── throw_on_dynamic_markup_insertion.html │ │ │ └── upgrade.html │ │ ├── document/ │ │ │ ├── adopt_import.html │ │ │ ├── all_collection.html │ │ │ ├── children.html │ │ │ ├── collections.html │ │ │ ├── create_element.html │ │ │ ├── create_element_ns.html │ │ │ ├── document-title.html │ │ │ ├── document.html │ │ │ ├── element_from_point.html │ │ │ ├── focus.html │ │ │ ├── get_element_by_id.html │ │ │ ├── get_elements_by_class_name-multiple.html │ │ │ ├── get_elements_by_class_name.html │ │ │ ├── get_elements_by_name.html │ │ │ ├── get_elements_by_tag_name-wildcard.html │ │ │ ├── get_elements_by_tag_name.html │ │ │ ├── insert_adjacent_element.html │ │ │ ├── insert_adjacent_html.html │ │ │ ├── insert_adjacent_text.html │ │ │ ├── query_selector.html │ │ │ ├── query_selector_all.html │ │ │ ├── query_selector_attributes.html │ │ │ ├── query_selector_edge_cases.html │ │ │ ├── query_selector_not.html │ │ │ ├── replace_children.html │ │ │ └── write.html │ │ ├── document_fragment/ │ │ │ ├── document_fragment.html │ │ │ └── insertion.html │ │ ├── document_head_body.html │ │ ├── domexception.html │ │ ├── domimplementation.html │ │ ├── domparser.html │ │ ├── element/ │ │ │ ├── append.html │ │ │ ├── attributes.html │ │ │ ├── bounding_rect.html │ │ │ ├── class_list.html │ │ │ ├── closest.html │ │ │ ├── css_style_properties.html │ │ │ ├── dataset.html │ │ │ ├── duplicate_ids.html │ │ │ ├── element.html │ │ │ ├── get_elements_by_class_name.html │ │ │ ├── get_elements_by_tag_name.html │ │ │ ├── get_elements_by_tag_name_ns.html │ │ │ ├── html/ │ │ │ │ ├── anchor.html │ │ │ │ ├── button.html │ │ │ │ ├── details.html │ │ │ │ ├── dialog.html │ │ │ │ ├── event_listeners.html │ │ │ │ ├── fieldset.html │ │ │ │ ├── form.html │ │ │ │ ├── htmlelement-props.html │ │ │ │ ├── image.html │ │ │ │ ├── input-attrs.html │ │ │ │ ├── input.html │ │ │ │ ├── input_click.html │ │ │ │ ├── input_radio.html │ │ │ │ ├── label.html │ │ │ │ ├── li.html │ │ │ │ ├── link.html │ │ │ │ ├── media.html │ │ │ │ ├── ol.html │ │ │ │ ├── optgroup.html │ │ │ │ ├── option.html │ │ │ │ ├── picture.html │ │ │ │ ├── quote.html │ │ │ │ ├── script/ │ │ │ │ │ ├── async_text.html │ │ │ │ │ ├── dynamic.html │ │ │ │ │ ├── dynamic1.js │ │ │ │ │ ├── dynamic2.js │ │ │ │ │ ├── dynamic_inline.html │ │ │ │ │ ├── empty.js │ │ │ │ │ ├── order.html │ │ │ │ │ ├── order.js │ │ │ │ │ ├── order_async.js │ │ │ │ │ ├── order_defer.js │ │ │ │ │ └── script.html │ │ │ │ ├── select.html │ │ │ │ ├── slot.html │ │ │ │ ├── style.html │ │ │ │ ├── tablecell.html │ │ │ │ ├── template.html │ │ │ │ ├── textarea.html │ │ │ │ ├── time.html │ │ │ │ └── track.html │ │ │ ├── inner.html │ │ │ ├── inner.js │ │ │ ├── matches.html │ │ │ ├── outer.html │ │ │ ├── position.html │ │ │ ├── pseudo_classes.html │ │ │ ├── query_selector.html │ │ │ ├── query_selector_all.html │ │ │ ├── query_selector_scope.html │ │ │ ├── remove.html │ │ │ ├── replace_with.html │ │ │ ├── selector_invalid.html │ │ │ ├── styles.html │ │ │ └── svg/ │ │ │ └── svg.html │ │ ├── encoding/ │ │ │ ├── text_decoder.html │ │ │ └── text_encoder.html │ │ ├── event/ │ │ │ ├── abort_controller.html │ │ │ ├── composition.html │ │ │ ├── custom_event.html │ │ │ ├── error.html │ │ │ ├── focus.html │ │ │ ├── keyboard.html │ │ │ ├── listener_removal.html │ │ │ ├── message.html │ │ │ ├── message_multiple_listeners.html │ │ │ ├── mouse.html │ │ │ ├── pointer.html │ │ │ ├── promise_rejection.html │ │ │ ├── report_error.html │ │ │ ├── text.html │ │ │ ├── ui.html │ │ │ └── wheel.html │ │ ├── events.html │ │ ├── file.html │ │ ├── file_reader.html │ │ ├── frames/ │ │ │ ├── frames.html │ │ │ ├── post_message.html │ │ │ ├── support/ │ │ │ │ ├── after_link.html │ │ │ │ ├── message_receiver.html │ │ │ │ ├── page.html │ │ │ │ ├── sub 1.html │ │ │ │ ├── sub2.html │ │ │ │ └── with_link.html │ │ │ └── target.html │ │ ├── history.html │ │ ├── history_after_nav.skip.html │ │ ├── image_data.html │ │ ├── integration/ │ │ │ └── custom_element_composition.html │ │ ├── intersection_observer/ │ │ │ ├── basic.html │ │ │ ├── disconnect.html │ │ │ ├── multiple_targets.html │ │ │ └── unobserve.html │ │ ├── legacy/ │ │ │ ├── browser.html │ │ │ ├── crypto.html │ │ │ ├── css.html │ │ │ ├── cssom/ │ │ │ │ ├── css_style_declaration.html │ │ │ │ └── css_stylesheet.html │ │ │ ├── dom/ │ │ │ │ ├── animation.html │ │ │ │ ├── attribute.html │ │ │ │ ├── character_data.html │ │ │ │ ├── comment.html │ │ │ │ ├── document.html │ │ │ │ ├── document_fragment.html │ │ │ │ ├── document_type.html │ │ │ │ ├── dom_parser.html │ │ │ │ ├── element.html │ │ │ │ ├── event_target.html │ │ │ │ ├── exceptions.html │ │ │ │ ├── html_collection.html │ │ │ │ ├── implementation.html │ │ │ │ ├── intersection_observer.html │ │ │ │ ├── named_node_map.html │ │ │ │ ├── node_filter.html │ │ │ │ ├── node_list.html │ │ │ │ ├── node_owner.html │ │ │ │ ├── performance.html │ │ │ │ ├── performance_observer.html │ │ │ │ ├── processing_instruction.html │ │ │ │ ├── range.html │ │ │ │ ├── text.html │ │ │ │ └── token_list.html │ │ │ ├── encoding/ │ │ │ │ ├── decoder.html │ │ │ │ └── encoder.html │ │ │ ├── events/ │ │ │ │ ├── composition.html │ │ │ │ ├── custom.html │ │ │ │ ├── event.html │ │ │ │ ├── keyboard.html │ │ │ │ └── mouse.html │ │ │ ├── fetch/ │ │ │ │ ├── fetch.html │ │ │ │ ├── headers.html │ │ │ │ ├── request.html │ │ │ │ └── response.html │ │ │ ├── file/ │ │ │ │ ├── blob.html │ │ │ │ └── file.html │ │ │ ├── html/ │ │ │ │ ├── abort_controller.html │ │ │ │ ├── canvas.html │ │ │ │ ├── dataset.html │ │ │ │ ├── document.html │ │ │ │ ├── element.html │ │ │ │ ├── error_event.html │ │ │ │ ├── history/ │ │ │ │ │ ├── history.html │ │ │ │ │ ├── history2.html │ │ │ │ │ └── history_after_nav.skip.html │ │ │ │ ├── image.html │ │ │ │ ├── input.html │ │ │ │ ├── link.html │ │ │ │ ├── location.html │ │ │ │ ├── navigation/ │ │ │ │ │ ├── navigation.html │ │ │ │ │ ├── navigation_after_nav.skip.html │ │ │ │ │ └── navigation_currententrychange.html │ │ │ │ ├── navigator.html │ │ │ │ ├── screen.html │ │ │ │ ├── script/ │ │ │ │ │ ├── dynamic_import.html │ │ │ │ │ ├── import.html │ │ │ │ │ ├── import.js │ │ │ │ │ ├── import2.js │ │ │ │ │ ├── importmap.html │ │ │ │ │ ├── inline_defer.html │ │ │ │ │ ├── inline_defer.js │ │ │ │ │ ├── order.html │ │ │ │ │ ├── order.js │ │ │ │ │ ├── order_async.js │ │ │ │ │ ├── order_defer.js │ │ │ │ │ └── script.html │ │ │ │ ├── select.html │ │ │ │ ├── slot.html │ │ │ │ ├── style.html │ │ │ │ └── svg.html │ │ │ ├── storage/ │ │ │ │ └── local_storage.html │ │ │ ├── streams/ │ │ │ │ └── readable_stream.html │ │ │ ├── testing.js │ │ │ ├── url/ │ │ │ │ ├── url.html │ │ │ │ └── url_search_params.html │ │ │ ├── window/ │ │ │ │ ├── frames.html │ │ │ │ └── window.html │ │ │ ├── xhr/ │ │ │ │ ├── form_data.html │ │ │ │ ├── progress_event.html │ │ │ │ └── xhr.html │ │ │ └── xmlserializer.html │ │ ├── mcp_actions.html │ │ ├── media/ │ │ │ ├── mediaerror.html │ │ │ └── vttcue.html │ │ ├── message_channel.html │ │ ├── mutation_observer/ │ │ │ ├── attribute_filter.html │ │ │ ├── character_data.html │ │ │ ├── childlist.html │ │ │ ├── multiple_observers.html │ │ │ ├── mutation_observer.html │ │ │ ├── mutations_during_callback.html │ │ │ ├── observe_multiple_targets.html │ │ │ ├── reobserve_same_target.html │ │ │ └── subtree.html │ │ ├── navigator/ │ │ │ └── navigator.html │ │ ├── net/ │ │ │ ├── fetch.html │ │ │ ├── form_data.html │ │ │ ├── headers.html │ │ │ ├── request.html │ │ │ ├── response.html │ │ │ ├── url_search_params.html │ │ │ └── xhr.html │ │ ├── node/ │ │ │ ├── adoption.html │ │ │ ├── append_child.html │ │ │ ├── base_uri.html │ │ │ ├── child_nodes.html │ │ │ ├── clone_node.html │ │ │ ├── compare_document_position.html │ │ │ ├── insert_before.html │ │ │ ├── is_connected.html │ │ │ ├── is_equal_node.html │ │ │ ├── node.html │ │ │ ├── node_iterator.html │ │ │ ├── normalize.html │ │ │ ├── noscript_serialization.html │ │ │ ├── owner.html │ │ │ ├── remove_child.html │ │ │ ├── replace_child.html │ │ │ ├── text_content.html │ │ │ ├── tree.html │ │ │ └── tree_walker.html │ │ ├── page/ │ │ │ ├── blob.html │ │ │ ├── load_event.html │ │ │ ├── meta.html │ │ │ ├── mod1.js │ │ │ ├── module.html │ │ │ └── modules/ │ │ │ ├── base.js │ │ │ ├── circular-a.js │ │ │ ├── circular-b.js │ │ │ ├── dynamic-chain-a.js │ │ │ ├── dynamic-chain-b.js │ │ │ ├── dynamic-chain-c.js │ │ │ ├── dynamic-circular-x.js │ │ │ ├── dynamic-circular-y.js │ │ │ ├── importer.js │ │ │ ├── mixed-circular-dynamic.js │ │ │ ├── mixed-circular-static.js │ │ │ ├── re-exporter.js │ │ │ ├── self_async.js │ │ │ ├── shared.js │ │ │ ├── syntax-error.js │ │ │ ├── test-404.js │ │ │ └── test-syntax-error.js │ │ ├── performance.html │ │ ├── performance_observer/ │ │ │ └── performance_observer.html │ │ ├── polyfill/ │ │ │ └── webcomponents.html │ │ ├── processing_instruction.html │ │ ├── range.html │ │ ├── range_mutations.html │ │ ├── selection.html │ │ ├── shadowroot/ │ │ │ ├── basic.html │ │ │ ├── custom_elements.html │ │ │ ├── dom_traversal.html │ │ │ ├── dump.html │ │ │ ├── edge_cases.html │ │ │ ├── events.html │ │ │ ├── id_collision.html │ │ │ ├── id_management.html │ │ │ ├── innerHTML_spec.html │ │ │ └── scoping.html │ │ ├── storage.html │ │ ├── streams/ │ │ │ ├── readable_stream.html │ │ │ ├── text_decoder_stream.html │ │ │ └── transform_stream.html │ │ ├── support/ │ │ │ └── history.html │ │ ├── testing.js │ │ ├── url.html │ │ ├── window/ │ │ │ ├── body_onload1.html │ │ │ ├── body_onload2.html │ │ │ ├── body_onload3.html │ │ │ ├── location.html │ │ │ ├── named_access.html │ │ │ ├── onerror.html │ │ │ ├── report_error.html │ │ │ ├── screen.html │ │ │ ├── scroll.html │ │ │ ├── stubs.html │ │ │ ├── timers.html │ │ │ ├── visual_viewport.html │ │ │ ├── window.html │ │ │ └── window_event.html │ │ ├── window_scroll.html │ │ └── xmlserializer.html │ └── webapi/ │ ├── AbortController.zig │ ├── AbortSignal.zig │ ├── AbstractRange.zig │ ├── Blob.zig │ ├── CData.zig │ ├── CSS.zig │ ├── Console.zig │ ├── Crypto.zig │ ├── CustomElementDefinition.zig │ ├── CustomElementRegistry.zig │ ├── DOMException.zig │ ├── DOMImplementation.zig │ ├── DOMNodeIterator.zig │ ├── DOMParser.zig │ ├── DOMRect.zig │ ├── DOMTreeWalker.zig │ ├── Document.zig │ ├── DocumentFragment.zig │ ├── DocumentType.zig │ ├── Element.zig │ ├── Event.zig │ ├── EventTarget.zig │ ├── File.zig │ ├── FileList.zig │ ├── FileReader.zig │ ├── HTMLDocument.zig │ ├── History.zig │ ├── IdleDeadline.zig │ ├── ImageData.zig │ ├── IntersectionObserver.zig │ ├── KeyValueList.zig │ ├── Location.zig │ ├── MessageChannel.zig │ ├── MessagePort.zig │ ├── MutationObserver.zig │ ├── Navigator.zig │ ├── Node.zig │ ├── NodeFilter.zig │ ├── Performance.zig │ ├── PerformanceObserver.zig │ ├── Permissions.zig │ ├── PluginArray.zig │ ├── Range.zig │ ├── ResizeObserver.zig │ ├── Screen.zig │ ├── Selection.zig │ ├── ShadowRoot.zig │ ├── StorageManager.zig │ ├── SubtleCrypto.zig │ ├── TreeWalker.zig │ ├── URL.zig │ ├── VisualViewport.zig │ ├── Window.zig │ ├── XMLDocument.zig │ ├── XMLSerializer.zig │ ├── animation/ │ │ └── Animation.zig │ ├── canvas/ │ │ ├── CanvasRenderingContext2D.zig │ │ ├── OffscreenCanvas.zig │ │ ├── OffscreenCanvasRenderingContext2D.zig │ │ └── WebGLRenderingContext.zig │ ├── cdata/ │ │ ├── CDATASection.zig │ │ ├── Comment.zig │ │ ├── ProcessingInstruction.zig │ │ └── Text.zig │ ├── children.zig │ ├── collections/ │ │ ├── ChildNodes.zig │ │ ├── DOMTokenList.zig │ │ ├── HTMLAllCollection.zig │ │ ├── HTMLCollection.zig │ │ ├── HTMLFormControlsCollection.zig │ │ ├── HTMLOptionsCollection.zig │ │ ├── NodeList.zig │ │ ├── RadioNodeList.zig │ │ ├── iterator.zig │ │ └── node_live.zig │ ├── collections.zig │ ├── css/ │ │ ├── CSSRule.zig │ │ ├── CSSRuleList.zig │ │ ├── CSSStyleDeclaration.zig │ │ ├── CSSStyleProperties.zig │ │ ├── CSSStyleRule.zig │ │ ├── CSSStyleSheet.zig │ │ ├── FontFace.zig │ │ ├── FontFaceSet.zig │ │ ├── MediaQueryList.zig │ │ └── StyleSheetList.zig │ ├── element/ │ │ ├── Attribute.zig │ │ ├── DOMStringMap.zig │ │ ├── Html.zig │ │ ├── Svg.zig │ │ ├── html/ │ │ │ ├── Anchor.zig │ │ │ ├── Area.zig │ │ │ ├── Audio.zig │ │ │ ├── BR.zig │ │ │ ├── Base.zig │ │ │ ├── Body.zig │ │ │ ├── Button.zig │ │ │ ├── Canvas.zig │ │ │ ├── Custom.zig │ │ │ ├── DList.zig │ │ │ ├── Data.zig │ │ │ ├── DataList.zig │ │ │ ├── Details.zig │ │ │ ├── Dialog.zig │ │ │ ├── Directory.zig │ │ │ ├── Div.zig │ │ │ ├── Embed.zig │ │ │ ├── FieldSet.zig │ │ │ ├── Font.zig │ │ │ ├── Form.zig │ │ │ ├── Generic.zig │ │ │ ├── HR.zig │ │ │ ├── Head.zig │ │ │ ├── Heading.zig │ │ │ ├── Html.zig │ │ │ ├── IFrame.zig │ │ │ ├── Image.zig │ │ │ ├── Input.zig │ │ │ ├── LI.zig │ │ │ ├── Label.zig │ │ │ ├── Legend.zig │ │ │ ├── Link.zig │ │ │ ├── Map.zig │ │ │ ├── Media.zig │ │ │ ├── Meta.zig │ │ │ ├── Meter.zig │ │ │ ├── Mod.zig │ │ │ ├── OL.zig │ │ │ ├── Object.zig │ │ │ ├── OptGroup.zig │ │ │ ├── Option.zig │ │ │ ├── Output.zig │ │ │ ├── Paragraph.zig │ │ │ ├── Param.zig │ │ │ ├── Picture.zig │ │ │ ├── Pre.zig │ │ │ ├── Progress.zig │ │ │ ├── Quote.zig │ │ │ ├── Script.zig │ │ │ ├── Select.zig │ │ │ ├── Slot.zig │ │ │ ├── Source.zig │ │ │ ├── Span.zig │ │ │ ├── Style.zig │ │ │ ├── Table.zig │ │ │ ├── TableCaption.zig │ │ │ ├── TableCell.zig │ │ │ ├── TableCol.zig │ │ │ ├── TableRow.zig │ │ │ ├── TableSection.zig │ │ │ ├── Template.zig │ │ │ ├── TextArea.zig │ │ │ ├── Time.zig │ │ │ ├── Title.zig │ │ │ ├── Track.zig │ │ │ ├── UL.zig │ │ │ ├── Unknown.zig │ │ │ └── Video.zig │ │ └── svg/ │ │ ├── Generic.zig │ │ └── Rect.zig │ ├── encoding/ │ │ ├── TextDecoder.zig │ │ ├── TextDecoderStream.zig │ │ ├── TextEncoder.zig │ │ └── TextEncoderStream.zig │ ├── event/ │ │ ├── CompositionEvent.zig │ │ ├── CustomEvent.zig │ │ ├── ErrorEvent.zig │ │ ├── FocusEvent.zig │ │ ├── InputEvent.zig │ │ ├── KeyboardEvent.zig │ │ ├── MessageEvent.zig │ │ ├── MouseEvent.zig │ │ ├── NavigationCurrentEntryChangeEvent.zig │ │ ├── PageTransitionEvent.zig │ │ ├── PointerEvent.zig │ │ ├── PopStateEvent.zig │ │ ├── ProgressEvent.zig │ │ ├── PromiseRejectionEvent.zig │ │ ├── TextEvent.zig │ │ ├── UIEvent.zig │ │ └── WheelEvent.zig │ ├── global_event_handlers.zig │ ├── media/ │ │ ├── MediaError.zig │ │ ├── TextTrackCue.zig │ │ └── VTTCue.zig │ ├── navigation/ │ │ ├── Navigation.zig │ │ ├── NavigationActivation.zig │ │ ├── NavigationHistoryEntry.zig │ │ └── root.zig │ ├── net/ │ │ ├── Fetch.zig │ │ ├── FormData.zig │ │ ├── Headers.zig │ │ ├── Request.zig │ │ ├── Response.zig │ │ ├── URLSearchParams.zig │ │ ├── XMLHttpRequest.zig │ │ └── XMLHttpRequestEventTarget.zig │ ├── selector/ │ │ ├── List.zig │ │ ├── Parser.zig │ │ └── Selector.zig │ ├── storage/ │ │ ├── Cookie.zig │ │ └── storage.zig │ └── streams/ │ ├── ReadableStream.zig │ ├── ReadableStreamDefaultController.zig │ ├── ReadableStreamDefaultReader.zig │ ├── TransformStream.zig │ ├── WritableStream.zig │ ├── WritableStreamDefaultController.zig │ └── WritableStreamDefaultWriter.zig ├── cdp/ │ ├── AXNode.zig │ ├── Node.zig │ ├── cdp.zig │ ├── domains/ │ │ ├── accessibility.zig │ │ ├── browser.zig │ │ ├── css.zig │ │ ├── dom.zig │ │ ├── emulation.zig │ │ ├── fetch.zig │ │ ├── input.zig │ │ ├── inspector.zig │ │ ├── log.zig │ │ ├── lp.zig │ │ ├── network.zig │ │ ├── page.zig │ │ ├── performance.zig │ │ ├── runtime.zig │ │ ├── security.zig │ │ ├── storage.zig │ │ └── target.zig │ ├── id.zig │ └── testing.zig ├── crash_handler.zig ├── crypto.zig ├── data/ │ ├── public_suffix_list.zig │ └── public_suffix_list_gen.go ├── datetime.zig ├── html5ever/ │ ├── Cargo.toml │ ├── lib.rs │ ├── sink.rs │ └── types.rs ├── id.zig ├── lightpanda.zig ├── log.zig ├── main.zig ├── main_legacy_test.zig ├── main_snapshot_creator.zig ├── mcp/ │ ├── Server.zig │ ├── protocol.zig │ ├── resources.zig │ ├── router.zig │ └── tools.zig ├── mcp.zig ├── network/ │ ├── Robots.zig │ ├── Runtime.zig │ ├── WebBotAuth.zig │ ├── http.zig │ └── websocket.zig ├── slab.zig ├── string.zig ├── sys/ │ └── libcurl.zig ├── telemetry/ │ ├── lightpanda.zig │ └── telemetry.zig ├── test_runner.zig └── testing.zig ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/actions/install/action.yml ================================================ name: "Browsercore install" description: "Install deps for the project browsercore" inputs: arch: description: 'CPU arch used to select the v8 lib' required: false default: 'x86_64' os: description: 'OS used to select the v8 lib' required: false default: 'linux' zig-v8: description: 'zig v8 version to install' required: false default: 'v0.3.4' v8: description: 'v8 version to install' required: false default: '14.0.365.4' cache-dir: description: 'cache dir to use' required: false default: '~/.cache' debug: description: 'enable v8 pre-built debug version, only available for linux x86_64' required: false default: 'false' runs: using: "composite" steps: - name: Install apt deps if: ${{ inputs.os == 'linux' }} shell: bash run: | sudo apt-get update sudo apt-get install -y wget xz-utils ca-certificates clang make git # Zig version used from the `minimum_zig_version` field in build.zig.zon - uses: mlugg/setup-zig@v2 # Rust Toolchain for html5ever - uses: dtolnay/rust-toolchain@stable - name: Cache v8 id: cache-v8 uses: actions/cache@v5 env: cache-name: cache-v8 with: path: ${{ inputs.cache-dir }}/v8 key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}${{inputs.debug == 'true' && '_debug' || '' }}.a - if: ${{ steps.cache-v8.outputs.cache-hit != 'true' }} shell: bash run: | mkdir -p ${{ inputs.cache-dir }}/v8 wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}${{inputs.debug == 'true' && '_debug' || '' }}.a - name: install v8 shell: bash run: | mkdir -p v8 ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8${{inputs.debug == 'true' && '_debug' || '' }}.a ================================================ FILE: .github/workflows/cla.yml ================================================ name: "CLA Assistant" on: issue_comment: types: [created] pull_request_target: types: [opened,closed,synchronize] permissions: actions: write contents: read pull-requests: write statuses: write jobs: CLAAssistant: runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: "CLA Assistant" if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' uses: contributor-assistant/github-action@v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_GH_PAT }} with: path-to-signatures: 'signatures/browser/version1/cla.json' path-to-document: 'https://github.com/lightpanda-io/browser/blob/main/CLA.md' # branch should not be protected branch: 'main' allowlist: krichprollsch,francisbouvier,katie-lpd,sjorsdonkers,bornlex remote-organization-name: lightpanda-io remote-repository-name: cla ================================================ FILE: .github/workflows/e2e-integration-test.yml ================================================ name: e2e-integration-test env: LIGHTPANDA_DISABLE_TELEMETRY: true on: schedule: - cron: "4 4 * * *" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: zig-build-release: name: zig build release runs-on: ubuntu-latest timeout-minutes: 15 # Don't run the CI with draft PR. if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install - name: zig build release run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact uses: actions/upload-artifact@v7 with: name: lightpanda-build-release path: | zig-out/bin/lightpanda retention-days: 1 demo-scripts: name: demo-integration-scripts needs: zig-build-release runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 - run: npm install - name: download artifact uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda - name: run end to end integration tests run: | ./lightpanda serve --log_level error & echo $! > LPD.pid go run integration/main.go kill `cat LPD.pid` ================================================ FILE: .github/workflows/e2e-test.yml ================================================ name: e2e-test env: AWS_ACCESS_KEY_ID: ${{ vars.LPD_PERF_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }} AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }} LIGHTPANDA_DISABLE_TELEMETRY: true on: push: branches: [main] paths: - ".github/**" - "src/**" - "build.zig" - "build.zig.zon" pull_request: # By default GH trigger on types opened, synchronize and reopened. # see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request # Since we skip the job when the PR is in draft state, we want to force CI # running when the PR is marked ready_for_review w/o other change. # see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917 types: [opened, synchronize, reopened, ready_for_review] paths: - ".github/**" - "src/**" - "build.zig" - "build.zig.zon" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: zig-build-release: name: zig build release runs-on: ubuntu-latest timeout-minutes: 15 # Don't run the CI with draft PR. if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install - name: zig build release run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact uses: actions/upload-artifact@v7 with: name: lightpanda-build-release path: | zig-out/bin/lightpanda retention-days: 1 demo-scripts: name: demo-scripts needs: zig-build-release runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 - run: npm install - name: download artifact uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda - name: run end to end tests run: | ./lightpanda serve & echo $! > LPD.pid go run runner/main.go kill `cat LPD.pid` - name: build proxy run: | cd proxy go build - name: run end to end tests through proxy run: | ./proxy/proxy & echo $! > PROXY.id ./lightpanda serve --http_proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid go run runner/main.go kill `cat LPD.pid` `cat PROXY.id` - name: run request interception through proxy run: | export PROXY_USERNAME=username PROXY_PASSWORD=password ./proxy/proxy & echo $! > PROXY.id ./lightpanda serve & echo $! > LPD.pid URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js kill `cat LPD.pid` `cat PROXY.id` # e2e tests w/ web-bot-auth configuration on. wba-demo-scripts: name: wba-demo-scripts needs: zig-build-release runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 - run: npm install - name: download artifact uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem - name: run end to end tests run: | ./lightpanda serve \ --web_bot_auth_key_file private_key.pem \ --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ & echo $! > LPD.pid go run runner/main.go kill `cat LPD.pid` - name: build proxy run: | cd proxy go build - name: run end to end tests through proxy run: | ./proxy/proxy & echo $! > PROXY.id ./lightpanda serve \ --web_bot_auth_key_file private_key.pem \ --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ --http_proxy 'http://127.0.0.1:3000' \ & echo $! > LPD.pid go run runner/main.go kill `cat LPD.pid` `cat PROXY.id` - name: run request interception through proxy run: | export PROXY_USERNAME=username PROXY_PASSWORD=password ./proxy/proxy & echo $! > PROXY.id ./lightpanda serve & echo $! > LPD.pid URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js kill `cat LPD.pid` `cat PROXY.id` wba-test: name: wba-test needs: zig-build-release runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 - name: download artifact uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda # force a wakup of the auth server before requesting it w/ the test itself - run: curl https://${{ vars.WBA_DOMAIN }} - name: run wba test shell: bash run: | node webbotauth/validator.js & VALIDATOR_PID=$! sleep 5 exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}" ./lightpanda fetch --dump http://127.0.0.1:8989/ \ --web_bot_auth_key_file /proc/self/fd/3 \ --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} wait $VALIDATOR_PID exec 3>&- cdp-and-hyperfine-bench: name: cdp-and-hyperfine-bench needs: zig-build-release env: MAX_VmHWM: 28000 # 28MB (KB) MAX_CG_PEAK: 8000 # 8MB (KB) MAX_AVG_DURATION: 17 # How to give cgroups access to the user actions-runner on the host: # $ sudo apt install cgroup-tools # $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs # $ sudo mkdir -p /sys/fs/cgroup/actions-runner # $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner CG_ROOT: /sys/fs/cgroup CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }} # use a self host runner. runs-on: lpd-bench-hetzner timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 - run: npm install - name: download artifact uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda - name: start http run: | go run ws/main.go & echo $! > WS.pid sleep 2 - name: run lightpanda in cgroup run: | if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then echo "cgroup v2 not available: /sys/fs/cgroup/cgroup.controllers missing" exit 1 fi mkdir -p $CG_ROOT/$CG cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.pid sleep 2 - name: run puppeteer run: | RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1 cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM kill `cat LPD.pid` PID=$(cat LPD.pid) while kill -0 $PID 2>/dev/null; do sleep 1 done if [ ! -f $CG_ROOT/$CG/memory.peak ]; then echo "memory.peak not available in $CG" exit 1 fi cat $CG_ROOT/$CG/memory.peak > LPD.cg_mem_peak - name: puppeteer result run: cat puppeteer.out - name: cgroup memory regression run: | PEAK_BYTES=$(cat LPD.cg_mem_peak) PEAK_KB=$((PEAK_BYTES / 1024)) echo "memory.peak_bytes=$PEAK_BYTES" echo "memory.peak_kb=$PEAK_KB" test "$PEAK_KB" -le "$MAX_CG_PEAK" - name: virtual memory regression run: | export LPD_VmHWM=`cat LPD.VmHWM` echo "Peak resident set size: $LPD_VmHWM" test "$LPD_VmHWM" -le "$MAX_VmHWM" - name: cleanup cgroup run: rmdir $CG_ROOT/$CG - name: duration regression run: | export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'` echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION" test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION" - name: json output run: | export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'` export TOTAL_DURATION=`cat puppeteer.out|grep 'total duration'|sed 's/total duration (ms) //'` export LPD_VmHWM=`cat LPD.VmHWM` export LPD_CG_PEAK_KB=$(( $(cat LPD.cg_mem_peak) / 1024 )) echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM},\"cg_mem_peak\":${LPD_CG_PEAK_KB}}" > bench.json cat bench.json - name: run hyperfine run: | hyperfine --export-json=hyperfine.json --warmup 3 --runs 20 --shell=none "./lightpanda --dump http://127.0.0.1:1234/campfire-commerce/" - name: stop http run: kill `cat WS.pid` - name: write commit run: | echo "${{github.sha}}" > commit.txt - name: upload artifact uses: actions/upload-artifact@v7 with: name: bench-results path: | bench.json hyperfine.json commit.txt retention-days: 10 perf-fmt: name: perf-fmt needs: cdp-and-hyperfine-bench # Don't execute on PR if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 15 container: image: ghcr.io/lightpanda-io/perf-fmt:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - name: download artifact uses: actions/download-artifact@v8 with: name: bench-results - name: format and send json result run: /perf-fmt cdp ${{ github.sha }} bench.json - name: format and send json result run: /perf-fmt hyperfine ${{ github.sha }} hyperfine.json browser-fetch: name: browser fetch needs: zig-build-release runs-on: ubuntu-latest steps: - name: download artifact uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda - run: ./lightpanda fetch https://demo-browser.lightpanda.io/campfire-commerce/ ================================================ FILE: .github/workflows/nightly.yml ================================================ name: nightly build env: AWS_ACCESS_KEY_ID: ${{ vars.NIGHTLY_BUILD_AWS_ACCESS_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }} AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }} RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }} on: push: tags: - '*' schedule: - cron: "2 2 * * *" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: permissions: contents: write jobs: build-linux-x86_64: env: ARCH: x86_64 OS: linux runs-on: ubuntu-22.04 timeout-minutes: 20 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install with: os: ${{env.OS}} arch: ${{env.ARCH}} - name: v8 snapshot run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: upload on s3 run: | export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'` aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: Upload the build uses: ncipollo/release-action@v1 with: allowUpdates: true artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }} tag: ${{ env.RELEASE }} makeLatest: true build-linux-aarch64: env: ARCH: aarch64 OS: linux runs-on: ubuntu-22.04-arm timeout-minutes: 20 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install with: os: ${{env.OS}} arch: ${{env.ARCH}} - name: v8 snapshot run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: upload on s3 run: | export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'` aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: Upload the build uses: ncipollo/release-action@v1 with: allowUpdates: true artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }} tag: ${{ env.RELEASE }} makeLatest: true build-macos-aarch64: env: ARCH: aarch64 OS: macos # macos-14 runs on arm CPU. see # https://github.com/actions/runner-images?tab=readme-ov-file runs-on: macos-14 timeout-minutes: 20 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install with: os: ${{env.OS}} arch: ${{env.ARCH}} - name: v8 snapshot run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: upload on s3 run: | export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'` aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: Upload the build uses: ncipollo/release-action@v1 with: allowUpdates: true artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }} tag: ${{ env.RELEASE }} makeLatest: true build-macos-x86_64: env: ARCH: x86_64 OS: macos runs-on: macos-14-large timeout-minutes: 20 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install with: os: ${{env.OS}} arch: ${{env.ARCH}} - name: v8 snapshot run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: upload on s3 run: | export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'` aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }} - name: Upload the build uses: ncipollo/release-action@v1 with: allowUpdates: true artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }} tag: ${{ env.RELEASE }} makeLatest: true ================================================ FILE: .github/workflows/wpt.yml ================================================ name: wpt env: AWS_ACCESS_KEY_ID: ${{ vars.LPD_PERF_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }} AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }} AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }} LIGHTPANDA_DISABLE_TELEMETRY: true on: schedule: - cron: "21 2 * * *" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: wpt-build-release: name: zig build release env: ARCH: aarch64 OS: linux runs-on: ubuntu-24.04-arm timeout-minutes: 20 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install with: os: ${{env.OS}} arch: ${{env.ARCH}} - name: v8 snapshot run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build release run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact uses: actions/upload-artifact@v7 with: name: lightpanda-build-release path: | zig-out/bin/lightpanda retention-days: 1 wpt-build-runner: name: build wpt runner runs-on: ubuntu-24.04-arm timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: repository: 'lightpanda-io/demo' fetch-depth: 0 - run: | cd ./wptrunner CGO_ENABLED=0 go build - name: upload artifact uses: actions/upload-artifact@v7 with: name: wptrunner path: | wptrunner/wptrunner retention-days: 1 run-wpt: name: web platform tests json output needs: - wpt-build-release - wpt-build-runner # use a self host runner. runs-on: lpd-wpt-aws timeout-minutes: 600 steps: - uses: actions/checkout@v6 with: ref: fork repository: 'lightpanda-io/wpt' fetch-depth: 0 # The hosts are configured manually on the self host runner. # - name: create custom hosts # run: ./wpt make-hosts-file | sudo tee -a /etc/hosts - name: generate manifest run: ./wpt manifest - name: download lightpanda release uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda - name: download wptrunner uses: actions/download-artifact@v8 with: name: wptrunner - run: chmod a+x ./wptrunner - name: run test with json output run: | ./wpt serve 2> /dev/null & echo $! > WPT.pid sleep 20s ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json kill `cat WPT.pid` - name: write commit run: | echo "${{github.sha}}" > commit.txt - name: upload artifact uses: actions/upload-artifact@v7 with: name: wpt-results path: | wpt.json commit.txt retention-days: 10 perf-fmt: name: perf-fmt needs: run-wpt runs-on: ubuntu-latest timeout-minutes: 15 container: image: ghcr.io/lightpanda-io/perf-fmt:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - name: download artifact uses: actions/download-artifact@v8 with: name: wpt-results - name: format and send json result run: /perf-fmt wpt ${{ github.sha }} wpt.json ================================================ FILE: .github/workflows/zig-test.yml ================================================ name: zig-test env: AWS_ACCESS_KEY_ID: ${{ vars.LPD_PERF_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }} AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }} AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }} LIGHTPANDA_DISABLE_TELEMETRY: true on: push: branches: [main] paths: - ".github/**" - "src/**" - "build.zig" - "build.zig.zon" pull_request: # By default GH trigger on types opened, synchronize and reopened. # see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request # Since we skip the job when the PR is in draft state, we want to force CI # running when the PR is marked ready_for_review w/o other change. # see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917 types: [opened, synchronize, reopened, ready_for_review] paths: - ".github/**" - "src/**" - "build.zig" - "build.zig.zon" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: zig-fmt: name: zig fmt runs-on: ubuntu-latest timeout-minutes: 15 if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 # Zig version used from the `minimum_zig_version` field in build.zig.zon - uses: mlugg/setup-zig@v2 - name: Run zig fmt id: fmt run: | zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed" delimiter="$(openssl rand -hex 8)" echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}" if [ -s zig-fmt.err ]; then echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}" cat zig-fmt.err >> "${GITHUB_OUTPUT}" fi if [ -s zig-fmt.err2 ]; then echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}" cat zig-fmt.err2 >> "${GITHUB_OUTPUT}" fi echo "${delimiter}" >> "${GITHUB_OUTPUT}" - name: Fail the job if: steps.fmt.outputs.zig_fmt_errs != '' run: exit 1 zig-test-debug: name: zig test using v8 in debug mode runs-on: ubuntu-latest timeout-minutes: 15 if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install with: debug: true - name: zig build test run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test zig-test-release: name: zig test runs-on: ubuntu-latest timeout-minutes: 15 if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: ./.github/actions/install - name: zig build test run: METRICS=true zig build -Dprebuilt_v8_path=v8/libc_v8.a test > bench.json - name: write commit run: | echo "${{github.sha}}" > commit.txt - name: upload artifact uses: actions/upload-artifact@v7 with: name: bench-results path: | bench.json commit.txt retention-days: 10 bench-fmt: name: perf-fmt needs: zig-test-release runs-on: ubuntu-latest timeout-minutes: 15 if: github.event_name != 'pull_request' container: image: ghcr.io/lightpanda-io/perf-fmt:latest credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - name: download artifact uses: actions/download-artifact@v8 with: name: bench-results - name: format and send json result run: /perf-fmt bench-browser ${{ github.sha }} bench.json ================================================ FILE: .gitignore ================================================ /.zig-cache/ /.lp-cache/ zig-out lightpanda.id /src/html5ever/target/ src/snapshot.bin ================================================ FILE: CLA.md ================================================ # Lightpanda (Selecy SAS) Grant and Contributor License Agreement (“Agreement”) This agreement is based on the Apache Software Foundation Contributor License Agreement. (v r190612) Thank you for your interest in software projects stewarded by Lightpanda (Selecy SAS) (“Lightpanda”). In order to clarify the intellectual property license granted with Contributions from any person or entity, Lightpanda must have a Contributor License Agreement (CLA) on file that has been agreed to by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of Lightpanda and its users; it does not change your rights to use your own Contributions for any other purpose. This Agreement allows an individual to contribute to Lightpanda on that individual’s own behalf, or an entity (the “Corporation”) to submit Contributions to Lightpanda, to authorize Contributions submitted by its designated employees to Lightpanda, and to grant copyright and patent licenses thereto. You accept and agree to the following terms and conditions for Your present and future Contributions submitted to Lightpanda. Except for the license granted herein to Lightpanda and recipients of software distributed by Lightpanda, You reserve all right, title, and interest in and to Your Contributions. 1. Definitions. “You” (or “Your”) shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Lightpanda. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, “control” means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. “Contribution” shall mean any work, as well as any modifications or additions to an existing work, that is intentionally submitted by You to Lightpanda for inclusion in, or documentation of, any of the products owned or managed by Lightpanda (the “Work”). For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to Lightpanda or its representatives, including but not limited to communication on electronic mailing lists, source code control systems (such as GitHub), and issue tracking systems that are managed by, or on behalf of, Lightpanda for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as “Not a Contribution.” 2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to Lightpanda and to recipients of software distributed by Lightpanda a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. 3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to Lightpanda and to recipients of software distributed by Lightpanda a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) were submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. 4. You represent that You are legally entitled to grant the above license. If You are an individual, and if Your employer(s) has rights to intellectual property that you create that includes Your Contributions, you represent that You have received permission to make Contributions on behalf of that employer, or that Your employer has waived such rights for your Contributions to Lightpanda. If You are a Corporation, any individual who makes a contribution from an account associated with You will be considered authorized to Contribute on Your behalf. 5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). 6. You are not expected to provide support for Your Contributions,except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. 7. Should You wish to submit work that is not Your original creation, You may submit it to Lightpanda separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as “Submitted on behalf of a third-party: [named here]”. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Lightpanda accepts pull requests through GitHub. You have to sign our [CLA](CLA.md) during your first pull request process otherwise we're not able to accept your contributions. The process signature uses the [CLA assistant lite](https://github.com/marketplace/actions/cla-assistant-lite). You can see an example of the process in [#303](https://github.com/lightpanda-io/browser/pull/303). ================================================ FILE: Dockerfile ================================================ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 ARG ZIG_V8=v0.3.4 ARG TARGETPLATFORM RUN apt-get update -yq && \ apt-get install -yq xz-utils ca-certificates \ pkg-config libglib2.0-dev \ clang make curl git # Get Rust RUN curl https://sh.rustup.rs -sSf | sh -s -- --profile minimal -y ENV PATH="/root/.cargo/bin:${PATH}" # install minisig RUN curl --fail -L -O https://github.com/jedisct1/minisign/releases/download/${MINISIG}/minisign-${MINISIG}-linux.tar.gz && \ tar xvzf minisign-${MINISIG}-linux.tar.gz -C / # clone lightpanda RUN git clone https://github.com/lightpanda-io/browser.git WORKDIR /browser # install zig RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \ case $TARGETPLATFORM in \ "linux/arm64") ARCH="aarch64" ;; \ *) ARCH="x86_64" ;; \ esac && \ curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz && \ curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig && \ /minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG} && \ tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \ mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \ ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig # download and install v8 RUN case $TARGETPLATFORM in \ "linux/arm64") ARCH="aarch64" ;; \ *) ARCH="x86_64" ;; \ esac && \ curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \ mkdir -p v8/ && \ mv libc_v8.a v8/libc_v8.a # build v8 snapshot RUN zig build -Doptimize=ReleaseFast \ -Dprebuilt_v8_path=v8/libc_v8.a \ snapshot_creator -- src/snapshot.bin # build release RUN zig build -Doptimize=ReleaseFast \ -Dsnapshot_path=../../snapshot.bin \ -Dprebuilt_v8_path=v8/libc_v8.a \ -Dgit_commit=$(git rev-parse --short HEAD) FROM debian:stable-slim RUN apt-get update -yq && \ apt-get install -yq tini FROM debian:stable-slim # copy ca certificates COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=0 /browser/zig-out/bin/lightpanda /bin/lightpanda COPY --from=1 /usr/bin/tini /usr/bin/tini EXPOSE 9222/tcp # Lightpanda install only some signal handlers, and PID 1 doesn't have a default SIGTERM signal handler. # Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang. # (See https://github.com/krallin/tini#why-tini). ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log_level", "info"] ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: LICENSING.md ================================================ # Licensing License names used in this document are as per [SPDX License List](https://spdx.org/licenses/). The default license for this project is [AGPL-3.0-only](LICENSE). The following directories and their subdirectories are licensed under their original upstream licenses: ``` vendor/ tests/wpt/ ``` ================================================ FILE: Makefile ================================================ # Variables # --------- ZIG := zig BC := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) # option test filter make test F="server" F= # OS and ARCH kernel = $(shell uname -ms) ifeq ($(kernel), Darwin arm64) OS := macos ARCH := aarch64 else ifeq ($(kernel), Darwin x86_64) OS := macos ARCH := x86_64 else ifeq ($(kernel), Linux aarch64) OS := linux ARCH := aarch64 else ifeq ($(kernel), Linux arm64) OS := linux ARCH := aarch64 else ifeq ($(kernel), Linux x86_64) OS := linux ARCH := x86_64 else $(error "Unhandled kernel: $(kernel)") endif # Infos # ----- .PHONY: help ## Display this help screen help: @printf "\033[36m%-35s %s\033[0m\n" "Command" "Usage" @sed -n -e '/^## /{'\ -e 's/## //g;'\ -e 'h;'\ -e 'n;'\ -e 's/:.*//g;'\ -e 'G;'\ -e 's/\n/ /g;'\ -e 'p;}' Makefile | awk '{printf "\033[33m%-35s\033[0m%s\n", $$1, substr($$0,length($$1)+1)}' # $(ZIG) commands # ------------ .PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end ## Build v8 snapshot build-v8-snapshot: @printf "\033[36mBuilding v8 snapshot (release safe)...\033[0m\n" @$(ZIG) build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @printf "\033[33mBuild OK\033[0m\n" ## Build in release-fast mode build: build-v8-snapshot @printf "\033[36mBuilding (release fast)...\033[0m\n" @$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @printf "\033[33mBuild OK\033[0m\n" ## Build in debug mode build-dev: @printf "\033[36mBuilding (debug)...\033[0m\n" @$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @printf "\033[33mBuild OK\033[0m\n" ## Run the server in release mode run: build @printf "\033[36mRunning...\033[0m\n" @./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;) ## Run the server in debug mode run-debug: build-dev @printf "\033[36mRunning...\033[0m\n" @./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;) ## Test - `grep` is used to filter out the huge compile command on build ifeq ($(OS), macos) test: @script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' 2>&1 \ | grep --line-buffered -v "^/.*zig test -freference-trace" else test: @script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' /dev/null 2>&1 \ | grep --line-buffered -v "^/.*zig test -freference-trace" endif ## Run demo/runner end to end tests end2end: @test -d ../demo cd ../demo && go run runner/main.go # Install and build required dependencies commands # ------------ .PHONY: install install: build data: cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig ================================================ FILE: README.md ================================================

Logo

Lightpanda Browser

The headless browser built from scratch for AI agents and automation.
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.

[![License](https://img.shields.io/github/license/lightpanda-io/browser)](https://github.com/lightpanda-io/browser/blob/main/LICENSE) [![Twitter Follow](https://img.shields.io/twitter/follow/lightpanda_io)](https://twitter.com/lightpanda_io) [![GitHub stars](https://img.shields.io/github/stars/lightpanda-io/browser)](https://github.com/lightpanda-io/browser) [![Discord](https://img.shields.io/discord/1391984864894521354?style=flat-square&label=discord)](https://discord.gg/K63XeymfB5)
[ ](https://github.com/lightpanda-io/demo)   [ ](https://github.com/lightpanda-io/demo)
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance. See [benchmark details](https://github.com/lightpanda-io/demo)._ Lightpanda is the open-source browser made for headless usage: - Javascript execution - Support of Web APIs (partial, WIP) - Compatible with Playwright[^1], Puppeteer, chromedp through [CDP](https://chromedevtools.github.io/devtools-protocol/) Fast web automation for AI agents, LLM training, scraping and testing: - Ultra-low memory footprint (9x less than Chrome) - Exceptionally fast execution (11x faster than Chrome) - Instant startup [^1]: **Playwright support disclaimer:** Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script. ## Quick start ### Install **Install from the nightly builds** You can download the last binary from the [nightly builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for Linux x86_64 and MacOS aarch64. *For Linux* ```console curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && \ chmod a+x ./lightpanda ``` *For MacOS* ```console curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && \ chmod a+x ./lightpanda ``` *For Windows + WSL2* The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal. It is recommended to install clients like Puppeteer on the Windows host. **Install from Docker** Lightpanda provides [official Docker images](https://hub.docker.com/r/lightpanda/browser) for both Linux amd64 and arm64 architectures. The following command fetches the Docker image and starts a new container exposing Lightpanda's CDP server on port `9222`. ```console docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly ``` ### Dump a URL ```console ./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/ ``` ```console INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms] disabled = false INFO page : navigate . . . . . . . . . . . . . . . . . . . . [+6ms] url = https://demo-browser.lightpanda.io/campfire-commerce/ method = GET reason = address_bar body = false req_id = 1 INFO browser : executing script . . . . . . . . . . . . . . [+118ms] src = https://demo-browser.lightpanda.io/campfire-commerce/script.js kind = javascript cacheable = true INFO http : request complete . . . . . . . . . . . . . . . . [+140ms] source = xhr url = https://demo-browser.lightpanda.io/campfire-commerce/json/product.json status = 200 len = 4770 INFO http : request complete . . . . . . . . . . . . . . . . [+141ms] source = fetch url = https://demo-browser.lightpanda.io/campfire-commerce/json/reviews.json status = 200 len = 1615 ``` ### Start a CDP server ```console ./lightpanda serve --obey_robots --log_format pretty --log_level info --host 127.0.0.1 --port 9222 ``` ```console INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms] disabled = false INFO app : server running . . . . . . . . . . . . . . . . . [+0ms] address = 127.0.0.1:9222 ``` Once the CDP server started, you can run a Puppeteer script by configuring the `browserWSEndpoint`. ```js 'use strict' import puppeteer from 'puppeteer-core'; // use browserWSEndpoint to pass the Lightpanda's CDP server address. const browser = await puppeteer.connect({ browserWSEndpoint: "ws://127.0.0.1:9222", }); // The rest of your script remains the same. const context = await browser.createBrowserContext(); const page = await context.newPage(); // Dump all the links from the page. await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"}); const links = await page.evaluate(() => { return Array.from(document.querySelectorAll('a')).map(row => { return row.getAttribute('href'); }); }); console.log(links); await page.close(); await context.close(); await browser.disconnect(); ``` ### Telemetry By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable `LIGHTPANDA_DISABLE_TELEMETRY=true`. You can read Lightpanda's privacy policy at: [https://lightpanda.io/privacy-policy](https://lightpanda.io/privacy-policy). ## Status Lightpanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work. You may still encounter errors or crashes. Please open an issue with specifics if so. Here are the key features we have implemented: - [x] HTTP loader ([Libcurl](https://curl.se/libcurl/)) - [x] HTML parser ([html5ever](https://github.com/servo/html5ever)) - [x] DOM tree - [x] Javascript support ([v8](https://v8.dev/)) - [x] DOM APIs - [x] Ajax - [x] XHR API - [x] Fetch API - [x] DOM dump - [x] CDP/websockets server - [x] Click - [x] Input form - [x] Cookies - [x] Custom HTTP headers - [x] Proxy support - [x] Network interception - [x] Respect `robots.txt` with option `--obey_robots` NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time. ## Build from sources ### Prerequisites Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to install it with the right version in order to build the project. Lightpanda also depends on [v8](https://chromium.googlesource.com/v8/v8.git), [Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever). To be able to build the v8 engine, you have to install some libs: For **Debian/Ubuntu based Linux**: ``` sudo apt install xz-utils ca-certificates \ pkg-config libglib2.0-dev \ clang make curl git ``` You also need to [install Rust](https://rust-lang.org/tools/install/). For systems with [**Nix**](https://nixos.org/download/), you can use the devShell: ``` nix develop ``` For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/). ``` brew install cmake ``` ### Build and run You an build the entire browser with `make build` or `make build-dev` for debug env. But you can directly use the zig command: `zig build run`. #### Embed v8 snapshot Lighpanda uses v8 snapshot. By default, it is created on startup but you can embed it by using the following commands: Generate the snapshot. ``` zig build snapshot_creator -- src/snapshot.bin ``` Build using the snapshot binary. ``` zig build -Dsnapshot_path=../../snapshot.bin ``` See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details. ## Test ### Unit Tests You can test Lightpanda by running `make test`. ### End to end tests To run end to end tests, you need to clone the [demo repository](https://github.com/lightpanda-io/demo) into `../demo` dir. You have to install the [demo's node requirements](https://github.com/lightpanda-io/demo?tab=readme-ov-file#dependencies-1) You also need to install [Go](https://go.dev) > v1.24. ``` make end2end ``` ### Web Platform Tests Lightpanda is tested against the standardized [Web Platform Tests](https://web-platform-tests.org/). We use [a fork](https://github.com/lightpanda-io/wpt/tree/fork) including a custom [`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f). For reference, you can easily execute a WPT test case with your browser via [wpt.live](https://wpt.live). #### Configure WPT HTTP server To run the test, you must clone the repository, configure the custom hosts and generate the `MANIFEST.json` file. Clone the repository with the `fork` branch. ``` git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git ``` Enter into the `wpt/` dir. Install custom domains in your `/etc/hosts` ``` ./wpt make-hosts-file | sudo tee -a /etc/hosts ``` Generate `MANIFEST.json` ``` ./wpt manifest ``` Use the [WPT's setup guide](https://web-platform-tests.org/running-tests/from-local-system.html) for details. #### Run WPT test suite An external [Go](https://go.dev) runner is provided by [github.com/lightpanda-io/demo/](https://github.com/lightpanda-io/demo/) repository, located into `wptrunner/` dir. You need to clone the project first. First start the WPT's HTTP server from your `wpt/` clone dir. ``` ./wpt serve ``` Run a Lightpanda browser ``` zig build run -- --insecure_disable_tls_host_verification ``` Then you can start the wptrunner from the Demo's clone dir: ``` cd wptrunner && go run . ``` Or one specific test: ``` cd wptrunner && go run . Node-childNodes.html ``` `wptrunner` command accepts `--summary` and `--json` options modifying output. Also `--concurrency` define the concurrency limit. :warning: Running the whole test suite will take a long time. In this case, it's useful to build in `releaseFast` mode to make tests faster. ``` zig build -Doptimize=ReleaseFast run ``` ## Contributing Lightpanda accepts pull requests through GitHub. You have to sign our [CLA](CLA.md) during the pull request process otherwise we're not able to accept your contributions. ## Why? ### Javascript execution is mandatory for the modern web In the good old days, scraping a webpage was as easy as making an HTTP request, cURL-like. It’s not possible anymore, because Javascript is everywhere, like it or not: - Ajax, Single Page App, infinite loading, “click to display”, instant search, etc. - JS web frameworks: React, Vue, Angular & others ### Chrome is not the right tool If we need Javascript, why not use a real web browser? Take a huge desktop application, hack it, and run it on the server. Hundreds or thousands of instances of Chrome if you use it at scale. Are you sure it’s such a good idea? - Heavy on RAM and CPU, expensive to run - Hard to package, deploy and maintain at scale - Bloated, lots of features are not useful in headless usage ### Lightpanda is built for performance If we want both Javascript and performance in a true headless browser, we need to start from scratch. Not another iteration of Chromium, really from a blank page. Crazy right? But that’s what we did: - Not based on Chromium, Blink or WebKit - Low-level system programming language (Zig) with optimisations in mind - Opinionated: without graphical rendering ================================================ FILE: build.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Build = std.Build; pub fn build(b: *Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const manifest = Manifest.init(b); const git_commit = b.option([]const u8, "git_commit", "Current git commit"); const git_version = b.option([]const u8, "git_version", "Current git version (from tag)"); const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a"); const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot"); var opts = b.addOptions(); opts.addOption([]const u8, "version", manifest.version); opts.addOption([]const u8, "git_commit", git_commit orelse "dev"); opts.addOption(?[]const u8, "git_version", git_version orelse null); opts.addOption(?[]const u8, "snapshot_path", snapshot_path); const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false; const enable_asan = b.option(bool, "asan", "Enable Address Sanitizer") orelse false; const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers"); const lightpanda_module = blk: { const mod = b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig"), .target = target, .optimize = optimize, .link_libc = true, .link_libcpp = true, .sanitize_c = enable_csan, .sanitize_thread = enable_tsan, }); mod.addImport("lightpanda", mod); // allow circular "lightpanda" import mod.addImport("build_config", opts.createModule()); // Format check const fmt_step = b.step("fmt", "Check code formatting"); const fmt = b.addFmt(.{ .paths = &.{ "src", "build.zig", "build.zig.zon" }, .check = true, }); fmt_step.dependOn(&fmt.step); // Set default behavior b.default_step.dependOn(fmt_step); try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path); try linkCurl(b, mod, enable_tsan); try linkHtml5Ever(b, mod); break :blk mod; }; { // browser const exe = b.addExecutable(.{ .name = "lightpanda", .use_llvm = true, .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, .sanitize_c = enable_csan, .sanitize_thread = enable_tsan, .imports = &.{ .{ .name = "lightpanda", .module = lightpanda_module }, }, }), }); b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); if (b.args) |args| { run_cmd.addArgs(args); } const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); } { // snapshot creator const exe = b.addExecutable(.{ .name = "lightpanda-snapshot-creator", .use_llvm = true, .root_module = b.createModule(.{ .root_source_file = b.path("src/main_snapshot_creator.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "lightpanda", .module = lightpanda_module }, }, }), }); b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); if (b.args) |args| { run_cmd.addArgs(args); } const run_step = b.step("snapshot_creator", "Generate a v8 snapshot"); run_step.dependOn(&run_cmd.step); } { // test const tests = b.addTest(.{ .root_module = lightpanda_module, .use_llvm = true, .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple }, }); const run_tests = b.addRunArtifact(tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_tests.step); } { // browser const exe = b.addExecutable(.{ .name = "legacy_test", .use_llvm = true, .root_module = b.createModule(.{ .root_source_file = b.path("src/main_legacy_test.zig"), .target = target, .optimize = optimize, .sanitize_c = enable_csan, .sanitize_thread = enable_tsan, .imports = &.{ .{ .name = "lightpanda", .module = lightpanda_module }, }, }), }); b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); if (b.args) |args| { run_cmd.addArgs(args); } const run_step = b.step("legacy_test", "Run the app"); run_step.dependOn(&run_cmd.step); } } fn linkV8( b: *Build, mod: *Build.Module, is_asan: bool, is_tsan: bool, prebuilt_v8_path: ?[]const u8, ) !void { const target = mod.resolved_target.?; const dep = b.dependency("v8", .{ .target = target, .optimize = mod.optimize.?, .is_asan = is_asan, .is_tsan = is_tsan, .inspector_subtype = false, .v8_enable_sandbox = is_tsan, .cache_root = b.pathFromRoot(".lp-cache"), .prebuilt_v8_path = prebuilt_v8_path, }); mod.addImport("v8", dep.module("v8")); } fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void { const is_debug = if (mod.optimize.? == .Debug) true else false; const exec_cargo = b.addSystemCommand(&.{ "cargo", "build", "--profile", if (is_debug) "dev" else "release", "--manifest-path", "src/html5ever/Cargo.toml", }); // TODO: We can prefer `--artifact-dir` once it become stable. const out_dir = exec_cargo.addPrefixedOutputDirectoryArg("--target-dir=", "html5ever"); const html5ever_step = b.step("html5ever", "Install html5ever dependency (requires cargo)"); html5ever_step.dependOn(&exec_cargo.step); const obj = out_dir.path(b, if (is_debug) "debug" else "release").path(b, "liblitefetch_html5ever.a"); mod.addObjectFile(obj); } fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void { const target = mod.resolved_target.?; const curl = buildCurl(b, target, mod.optimize.?, is_tsan); mod.linkLibrary(curl); const zlib = buildZlib(b, target, mod.optimize.?, is_tsan); curl.root_module.linkLibrary(zlib); const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan); for (brotli) |lib| curl.root_module.linkLibrary(lib); const nghttp2 = buildNghttp2(b, target, mod.optimize.?, is_tsan); curl.root_module.linkLibrary(nghttp2); const boringssl = buildBoringSsl(b, target, mod.optimize.?); for (boringssl) |lib| curl.root_module.linkLibrary(lib); switch (target.result.os.tag) { .macos => { // needed for proxying on mac mod.addSystemFrameworkPath(.{ .cwd_relative = "/System/Library/Frameworks" }); mod.linkFramework("CoreFoundation", .{}); mod.linkFramework("SystemConfiguration", .{}); }, else => {}, } } fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile { const dep = b.dependency("zlib", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, }); const lib = b.addLibrary(.{ .name = "z", .root_module = mod }); lib.installHeadersDirectory(dep.path(""), "", .{}); lib.addCSourceFiles(.{ .root = dep.path(""), .flags = &.{ "-DHAVE_SYS_TYPES_H", "-DHAVE_STDINT_H", "-DHAVE_STDDEF_H", "-DHAVE_UNISTD_H", }, .files = &.{ "adler32.c", "compress.c", "crc32.c", "deflate.c", "gzclose.c", "gzlib.c", "gzread.c", "gzwrite.c", "infback.c", "inffast.c", "inflate.c", "inftrees.c", "trees.c", "uncompr.c", "zutil.c", }, }); return lib; } fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) [3]*Build.Step.Compile { const dep = b.dependency("brotli", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("c/include")); const brotlicmn = b.addLibrary(.{ .name = "brotlicommon", .root_module = mod }); const brotlidec = b.addLibrary(.{ .name = "brotlidec", .root_module = mod }); const brotlienc = b.addLibrary(.{ .name = "brotlienc", .root_module = mod }); brotlicmn.installHeadersDirectory(dep.path("c/include/brotli"), "brotli", .{}); brotlicmn.addCSourceFiles(.{ .root = dep.path("c/common"), .files = &.{ "transform.c", "shared_dictionary.c", "platform.c", "dictionary.c", "context.c", "constants.c", }, }); brotlidec.addCSourceFiles(.{ .root = dep.path("c/dec"), .files = &.{ "bit_reader.c", "decode.c", "huffman.c", "prefix.c", "state.c", "static_init.c", }, }); brotlienc.addCSourceFiles(.{ .root = dep.path("c/enc"), .files = &.{ "backward_references.c", "backward_references_hq.c", "bit_cost.c", "block_splitter.c", "brotli_bit_stream.c", "cluster.c", "command.c", "compound_dictionary.c", "compress_fragment.c", "compress_fragment_two_pass.c", "dictionary_hash.c", "encode.c", "encoder_dict.c", "entropy_encode.c", "fast_log.c", "histogram.c", "literal_cost.c", "memory.c", "metablock.c", "static_dict.c", "static_dict_lut.c", "static_init.c", "utf8_util.c", }, }); return .{ brotlicmn, brotlidec, brotlienc }; } fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [2]*Build.Step.Compile { const dep = b.dependency("boringssl-zig", .{ .target = target, .optimize = optimize, .force_pic = true, }); const ssl = dep.artifact("ssl"); ssl.bundle_ubsan_rt = false; const crypto = dep.artifact("crypto"); crypto.bundle_ubsan_rt = false; return .{ ssl, crypto }; } fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile { const dep = b.dependency("nghttp2", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("lib/includes")); const config = b.addConfigHeader(.{ .include_path = "nghttp2ver.h", .style = .{ .cmake = dep.path("lib/includes/nghttp2/nghttp2ver.h.in") }, }, .{ .PACKAGE_VERSION = "1.68.90", .PACKAGE_VERSION_NUM = 0x016890, }); mod.addConfigHeader(config); const lib = b.addLibrary(.{ .name = "nghttp2", .root_module = mod }); lib.installConfigHeader(config); lib.installHeadersDirectory(dep.path("lib/includes/nghttp2"), "nghttp2", .{}); lib.addCSourceFiles(.{ .root = dep.path("lib"), .flags = &.{ "-DNGHTTP2_STATICLIB", "-DHAVE_TIME_H", "-DHAVE_ARPA_INET_H", "-DHAVE_NETINET_IN_H", }, .files = &.{ "sfparse.c", "nghttp2_alpn.c", "nghttp2_buf.c", "nghttp2_callbacks.c", "nghttp2_debug.c", "nghttp2_extpri.c", "nghttp2_frame.c", "nghttp2_hd.c", "nghttp2_hd_huffman.c", "nghttp2_hd_huffman_data.c", "nghttp2_helper.c", "nghttp2_http.c", "nghttp2_map.c", "nghttp2_mem.c", "nghttp2_option.c", "nghttp2_outbound_item.c", "nghttp2_pq.c", "nghttp2_priority_spec.c", "nghttp2_queue.c", "nghttp2_rcbuf.c", "nghttp2_session.c", "nghttp2_stream.c", "nghttp2_submit.c", "nghttp2_version.c", "nghttp2_ratelim.c", "nghttp2_time.c", }, }); return lib; } fn buildCurl( b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool, ) *Build.Step.Compile { const dep = b.dependency("curl", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("lib")); mod.addIncludePath(dep.path("include")); const os = target.result.os.tag; const abi = target.result.abi; const is_gnu = abi.isGnu(); const is_ios = os == .ios; const is_android = abi.isAndroid(); const is_linux = os == .linux; const is_darwin = os.isDarwin(); const is_windows = os == .windows; const is_netbsd = os == .netbsd; const is_openbsd = os == .openbsd; const is_freebsd = os == .freebsd; const byte_size = struct { fn it(b2: *std.Build, target2: Build.ResolvedTarget, name: []const u8, comptime ctype: std.Target.CType) []const u8 { const size = target2.result.cTypeByteSize(ctype); return std.fmt.allocPrint(b2.allocator, "#define SIZEOF_{s} {d}", .{ name, size }) catch @panic("OOM"); } }.it; const config = .{ .HAVE_LIBZ = true, .HAVE_BROTLI = true, .USE_NGHTTP2 = true, .USE_OPENSSL = true, .OPENSSL_IS_BORINGSSL = true, .CURL_CA_PATH = null, .CURL_CA_BUNDLE = null, .CURL_CA_FALLBACK = false, .CURL_CA_SEARCH_SAFE = false, .CURL_DEFAULT_SSL_BACKEND = "openssl", .CURL_DISABLE_AWS = true, .CURL_DISABLE_DICT = true, .CURL_DISABLE_DOH = true, .CURL_DISABLE_FILE = true, .CURL_DISABLE_FTP = true, .CURL_DISABLE_GOPHER = true, .CURL_DISABLE_KERBEROS_AUTH = true, .CURL_DISABLE_IMAP = true, .CURL_DISABLE_IPFS = true, .CURL_DISABLE_LDAP = true, .CURL_DISABLE_LDAPS = true, .CURL_DISABLE_MQTT = true, .CURL_DISABLE_NTLM = true, .CURL_DISABLE_PROGRESS_METER = true, .CURL_DISABLE_POP3 = true, .CURL_DISABLE_RTSP = true, .CURL_DISABLE_SMB = true, .CURL_DISABLE_SMTP = true, .CURL_DISABLE_TELNET = true, .CURL_DISABLE_TFTP = true, .ssize_t = null, ._FILE_OFFSET_BITS = 64, .USE_IPV6 = true, .CURL_OS = switch (os) { .linux => if (is_android) "\"android\"" else "\"linux\"", else => std.fmt.allocPrint(b.allocator, "\"{s}\"", .{@tagName(os)}) catch @panic("OOM"), }, // Adjusts the sizes of variables .SIZEOF_INT_CODE = byte_size(b, target, "INT", .int), .SIZEOF_LONG_CODE = byte_size(b, target, "LONG", .long), .SIZEOF_LONG_LONG_CODE = byte_size(b, target, "LONG_LONG", .longlong), .SIZEOF_OFF_T_CODE = byte_size(b, target, "OFF_T", .longlong), .SIZEOF_CURL_OFF_T_CODE = byte_size(b, target, "CURL_OFF_T", .longlong), .SIZEOF_CURL_SOCKET_T_CODE = byte_size(b, target, "CURL_SOCKET_T", .int), .SIZEOF_SIZE_T_CODE = byte_size(b, target, "SIZE_T", .longlong), .SIZEOF_TIME_T_CODE = byte_size(b, target, "TIME_T", .longlong), // headers availability .HAVE_ARPA_INET_H = !is_windows, .HAVE_DIRENT_H = true, .HAVE_FCNTL_H = true, .HAVE_IFADDRS_H = !is_windows, .HAVE_IO_H = is_windows, .HAVE_LIBGEN_H = true, .HAVE_LINUX_TCP_H = is_linux and is_gnu, .HAVE_LOCALE_H = true, .HAVE_NETDB_H = !is_windows, .HAVE_NETINET_IN6_H = is_android, .HAVE_NETINET_IN_H = !is_windows, .HAVE_NETINET_TCP_H = !is_windows, .HAVE_NETINET_UDP_H = !is_windows, .HAVE_NET_IF_H = !is_windows, .HAVE_POLL_H = !is_windows, .HAVE_PWD_H = !is_windows, .HAVE_STDATOMIC_H = true, .HAVE_STDBOOL_H = true, .HAVE_STDDEF_H = true, .HAVE_STDINT_H = true, .HAVE_STRINGS_H = true, .HAVE_STROPTS_H = false, .HAVE_SYS_EVENTFD_H = is_linux or is_freebsd or is_netbsd, .HAVE_SYS_FILIO_H = !is_linux and !is_windows, .HAVE_SYS_IOCTL_H = !is_windows, .HAVE_SYS_PARAM_H = true, .HAVE_SYS_POLL_H = !is_windows, .HAVE_SYS_RESOURCE_H = !is_windows, .HAVE_SYS_SELECT_H = !is_windows, .HAVE_SYS_SOCKIO_H = !is_linux and !is_windows, .HAVE_SYS_TYPES_H = true, .HAVE_SYS_UN_H = !is_windows, .HAVE_SYS_UTIME_H = is_windows, .HAVE_TERMIOS_H = !is_windows, .HAVE_TERMIO_H = is_linux, .HAVE_UNISTD_H = true, .HAVE_UTIME_H = true, .STDC_HEADERS = true, // general environment .CURL_KRB5_VERSION = null, .HAVE_ALARM = !is_windows, .HAVE_ARC4RANDOM = is_android, .HAVE_ATOMIC = true, .HAVE_BOOL_T = true, .HAVE_BUILTIN_AVAILABLE = true, .HAVE_CLOCK_GETTIME_MONOTONIC = !is_darwin and !is_windows, .HAVE_CLOCK_GETTIME_MONOTONIC_RAW = is_linux, .HAVE_FILE_OFFSET_BITS = true, .HAVE_GETEUID = !is_windows, .HAVE_GETPPID = !is_windows, .HAVE_GETTIMEOFDAY = true, .HAVE_GLIBC_STRERROR_R = is_gnu, .HAVE_GMTIME_R = !is_windows, .HAVE_LOCALTIME_R = !is_windows, .HAVE_LONGLONG = !is_windows, .HAVE_MACH_ABSOLUTE_TIME = is_darwin, .HAVE_MEMRCHR = !is_darwin and !is_windows, .HAVE_POSIX_STRERROR_R = !is_gnu and !is_windows, .HAVE_PTHREAD_H = !is_windows, .HAVE_SETLOCALE = true, .HAVE_SETRLIMIT = !is_windows, .HAVE_SIGACTION = !is_windows, .HAVE_SIGINTERRUPT = !is_windows, .HAVE_SIGNAL = true, .HAVE_SIGSETJMP = !is_windows, .HAVE_SIZEOF_SA_FAMILY_T = false, .HAVE_SIZEOF_SUSECONDS_T = false, .HAVE_SNPRINTF = true, .HAVE_STRCASECMP = !is_windows, .HAVE_STRCMPI = false, .HAVE_STRDUP = true, .HAVE_STRERROR_R = !is_windows, .HAVE_STRICMP = false, .HAVE_STRUCT_TIMEVAL = true, .HAVE_TIME_T_UNSIGNED = false, .HAVE_UTIME = true, .HAVE_UTIMES = !is_windows, .HAVE_WRITABLE_ARGV = !is_windows, .HAVE__SETMODE = is_windows, .USE_THREADS_POSIX = !is_windows, // filesystem, network .HAVE_ACCEPT4 = is_linux or is_freebsd or is_netbsd or is_openbsd, .HAVE_BASENAME = true, .HAVE_CLOSESOCKET = is_windows, .HAVE_DECL_FSEEKO = !is_windows, .HAVE_EVENTFD = is_linux or is_freebsd or is_netbsd, .HAVE_FCNTL = !is_windows, .HAVE_FCNTL_O_NONBLOCK = !is_windows, .HAVE_FNMATCH = !is_windows, .HAVE_FREEADDRINFO = true, .HAVE_FSEEKO = !is_windows, .HAVE_FSETXATTR = is_darwin or is_linux or is_netbsd, .HAVE_FSETXATTR_5 = is_linux or is_netbsd, .HAVE_FSETXATTR_6 = is_darwin, .HAVE_FTRUNCATE = true, .HAVE_GETADDRINFO = true, .HAVE_GETADDRINFO_THREADSAFE = is_linux or is_freebsd or is_netbsd, .HAVE_GETHOSTBYNAME_R = is_linux or is_freebsd, .HAVE_GETHOSTBYNAME_R_3 = false, .HAVE_GETHOSTBYNAME_R_3_REENTRANT = false, .HAVE_GETHOSTBYNAME_R_5 = false, .HAVE_GETHOSTBYNAME_R_5_REENTRANT = false, .HAVE_GETHOSTBYNAME_R_6 = is_linux, .HAVE_GETHOSTBYNAME_R_6_REENTRANT = is_linux, .HAVE_GETHOSTNAME = true, .HAVE_GETIFADDRS = if (is_windows) false else !is_android or target.result.os.versionRange().linux.android >= 24, .HAVE_GETPASS_R = is_netbsd, .HAVE_GETPEERNAME = true, .HAVE_GETPWUID = !is_windows, .HAVE_GETPWUID_R = !is_windows, .HAVE_GETRLIMIT = !is_windows, .HAVE_GETSOCKNAME = true, .HAVE_IF_NAMETOINDEX = !is_windows, .HAVE_INET_NTOP = !is_windows, .HAVE_INET_PTON = !is_windows, .HAVE_IOCTLSOCKET = is_windows, .HAVE_IOCTLSOCKET_CAMEL = false, .HAVE_IOCTLSOCKET_CAMEL_FIONBIO = false, .HAVE_IOCTLSOCKET_FIONBIO = is_windows, .HAVE_IOCTL_FIONBIO = !is_windows, .HAVE_IOCTL_SIOCGIFADDR = !is_windows, .HAVE_MSG_NOSIGNAL = !is_windows, .HAVE_OPENDIR = true, .HAVE_PIPE = !is_windows, .HAVE_PIPE2 = is_linux or is_freebsd or is_netbsd or is_openbsd, .HAVE_POLL = !is_windows, .HAVE_REALPATH = !is_windows, .HAVE_RECV = true, .HAVE_SA_FAMILY_T = !is_windows, .HAVE_SCHED_YIELD = !is_windows, .HAVE_SELECT = true, .HAVE_SEND = true, .HAVE_SENDMMSG = !is_darwin and !is_windows, .HAVE_SENDMSG = !is_windows, .HAVE_SETMODE = !is_linux, .HAVE_SETSOCKOPT_SO_NONBLOCK = false, .HAVE_SOCKADDR_IN6_SIN6_ADDR = !is_windows, .HAVE_SOCKADDR_IN6_SIN6_SCOPE_ID = true, .HAVE_SOCKET = true, .HAVE_SOCKETPAIR = !is_windows, .HAVE_STRUCT_SOCKADDR_STORAGE = true, .HAVE_SUSECONDS_T = is_android or is_ios, .USE_UNIX_SOCKETS = !is_windows, }; const curl_config = b.addConfigHeader(.{ .include_path = "curl_config.h", .style = .{ .cmake = dep.path("lib/curl_config-cmake.h.in") }, }, .{ .CURL_EXTERN_SYMBOL = "__attribute__ ((__visibility__ (\"default\"))", }); curl_config.addValues(config); const lib = b.addLibrary(.{ .name = "curl", .root_module = mod }); lib.addConfigHeader(curl_config); lib.installHeadersDirectory(dep.path("include/curl"), "curl", .{}); lib.addCSourceFiles(.{ .root = dep.path("lib"), .flags = &.{ "-D_GNU_SOURCE", "-DHAVE_CONFIG_H", "-DCURL_STATICLIB", "-DBUILDING_LIBCURL", }, .files = &.{ // You can include all files from lib, libcurl uses #ifdef-guards to exclude code for disabled functions "altsvc.c", "amigaos.c", "asyn-ares.c", "asyn-base.c", "asyn-thrdd.c", "bufq.c", "bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c", "cf-haproxy.c", "cf-https-connect.c", "cf-ip-happy.c", "cf-socket.c", "cfilters.c", "conncache.c", "connect.c", "content_encoding.c", "cookie.c", "cshutdn.c", "curl_addrinfo.c", "curl_endian.c", "curl_fnmatch.c", "curl_fopen.c", "curl_get_line.c", "curl_gethostname.c", "curl_gssapi.c", "curl_memrchr.c", "curl_ntlm_core.c", "curl_range.c", "curl_rtmp.c", "curl_sasl.c", "curl_sha512_256.c", "curl_share.c", "curl_sspi.c", "curl_threads.c", "curl_trc.c", "curlx/base64.c", "curlx/dynbuf.c", "curlx/fopen.c", "curlx/inet_ntop.c", "curlx/inet_pton.c", "curlx/multibyte.c", "curlx/nonblock.c", "curlx/strcopy.c", "curlx/strerr.c", "curlx/strparse.c", "curlx/timediff.c", "curlx/timeval.c", "curlx/version_win32.c", "curlx/wait.c", "curlx/warnless.c", "curlx/winapi.c", "cw-out.c", "cw-pause.c", "dict.c", "dllmain.c", "doh.c", "dynhds.c", "easy.c", "easygetopt.c", "easyoptions.c", "escape.c", "fake_addrinfo.c", "file.c", "fileinfo.c", "formdata.c", "ftp.c", "ftplistparser.c", "getenv.c", "getinfo.c", "gopher.c", "hash.c", "headers.c", "hmac.c", "hostip.c", "hostip4.c", "hostip6.c", "hsts.c", "http.c", "http1.c", "http2.c", "http_aws_sigv4.c", "http_chunks.c", "http_digest.c", "http_negotiate.c", "http_ntlm.c", "http_proxy.c", "httpsrr.c", "idn.c", "if2ip.c", "imap.c", "ldap.c", "llist.c", "macos.c", "md4.c", "md5.c", "memdebug.c", "mime.c", "mprintf.c", "mqtt.c", "multi.c", "multi_ev.c", "multi_ntfy.c", "netrc.c", "noproxy.c", "openldap.c", "parsedate.c", "pingpong.c", "pop3.c", "progress.c", "psl.c", "rand.c", "ratelimit.c", "request.c", "rtsp.c", "select.c", "sendf.c", "setopt.c", "sha256.c", "slist.c", "smb.c", "smtp.c", "socketpair.c", "socks.c", "socks_gssapi.c", "socks_sspi.c", "splay.c", "strcase.c", "strdup.c", "strequal.c", "strerror.c", "system_win32.c", "telnet.c", "tftp.c", "transfer.c", "uint-bset.c", "uint-hash.c", "uint-spbset.c", "uint-table.c", "url.c", "urlapi.c", "vauth/cleartext.c", "vauth/cram.c", "vauth/digest.c", "vauth/digest_sspi.c", "vauth/gsasl.c", "vauth/krb5_gssapi.c", "vauth/krb5_sspi.c", "vauth/ntlm.c", "vauth/ntlm_sspi.c", "vauth/oauth2.c", "vauth/spnego_gssapi.c", "vauth/spnego_sspi.c", "vauth/vauth.c", "version.c", "vquic/curl_ngtcp2.c", "vquic/curl_osslq.c", "vquic/curl_quiche.c", "vquic/vquic-tls.c", "vquic/vquic.c", "vssh/libssh.c", "vssh/libssh2.c", "vssh/vssh.c", "vtls/apple.c", "vtls/cipher_suite.c", "vtls/gtls.c", "vtls/hostcheck.c", "vtls/keylog.c", "vtls/mbedtls.c", "vtls/openssl.c", "vtls/rustls.c", "vtls/schannel.c", "vtls/schannel_verify.c", "vtls/vtls.c", "vtls/vtls_scache.c", "vtls/vtls_spack.c", "vtls/wolfssl.c", "vtls/x509asn1.c", "ws.c", }, }); return lib; } const Manifest = struct { version: []const u8, minimum_zig_version: []const u8, fn init(b: *std.Build) Manifest { const input = @embedFile("build.zig.zon"); var diagnostics: std.zon.parse.Diagnostics = .{}; defer diagnostics.deinit(b.allocator); return std.zon.parse.fromSlice(Manifest, b.allocator, input, &diagnostics, .{ .free_on_error = true, .ignore_unknown_fields = true, }) catch |err| { switch (err) { error.OutOfMemory => @panic("OOM"), error.ParseZon => { std.debug.print("Parse diagnostics:\n{f}\n", .{diagnostics}); std.process.exit(1); }, } }; } }; ================================================ FILE: build.zig.zon ================================================ .{ .name = .browser, .version = "0.0.0", .fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications. .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz", .hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ // v1.2.0 .url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz", .hash = "N-V-__8AAJudKgCQCuIiH6MJjAiIJHfg_tT_Ew-0vZwVkCo_", }, .zlib = .{ .url = "https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz", .hash = "N-V-__8AAJ2cNgAgfBtAw33Bxfu1IWISDeKKSr3DAqoAysIJ", }, .nghttp2 = .{ .url = "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz", .hash = "N-V-__8AAL15vQCI63ZL6Zaz5hJg6JTEgYXGbLnMFSnf7FT3", }, .@"boringssl-zig" = .{ .url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096", .hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK", }, .curl = .{ .url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz", .hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y", }, }, .paths = .{""}, } ================================================ FILE: flake.nix ================================================ { description = "headless browser designed for AI and automation"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/release-25.05"; zigPkgs.url = "github:mitchellh/zig-overlay"; zigPkgs.inputs.nixpkgs.follows = "nixpkgs"; zlsPkg.url = "github:zigtools/zls/0.15.0"; zlsPkg.inputs.zig-overlay.follows = "zigPkgs"; zlsPkg.inputs.nixpkgs.follows = "nixpkgs"; fenix = { url = "github:nix-community/fenix"; inputs.nixpkgs.follows = "nixpkgs"; }; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { nixpkgs, zigPkgs, zlsPkg, fenix, flake-utils, ... }: flake-utils.lib.eachDefaultSystem ( system: let overlays = [ (final: prev: { zigpkgs = zigPkgs.packages.${prev.system}; zls = zlsPkg.packages.${prev.system}.default; }) ]; pkgs = import nixpkgs { inherit system overlays; }; rustToolchain = fenix.packages.${system}.stable.toolchain; # We need crtbeginS.o for building. crtFiles = pkgs.runCommand "crt-files" { } '' mkdir -p $out/lib cp -r ${pkgs.gcc.cc}/lib/gcc $out/lib/gcc ''; # This build pipeline is very unhappy without an FHS-compliant env. fhs = pkgs.buildFHSEnv { name = "fhs-shell"; multiArch = true; targetPkgs = pkgs: with pkgs; [ # Build Tools zigpkgs."0.15.2" zls rustToolchain python3 pkg-config cmake gperf # GCC gcc gcc.cc.lib crtFiles # Libaries expat.dev glib.dev glibc.dev zlib ]; }; in { devShells.default = fhs.env; } ); } ================================================ FILE: src/App.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Allocator = std.mem.Allocator; const log = @import("log.zig"); const Config = @import("Config.zig"); const Snapshot = @import("browser/js/Snapshot.zig"); const Platform = @import("browser/js/Platform.zig"); const Telemetry = @import("telemetry/telemetry.zig").Telemetry; const Network = @import("network/Runtime.zig"); pub const ArenaPool = @import("ArenaPool.zig"); const App = @This(); network: Network, config: *const Config, platform: Platform, snapshot: Snapshot, telemetry: Telemetry, allocator: Allocator, arena_pool: ArenaPool, app_dir_path: ?[]const u8, pub fn init(allocator: Allocator, config: *const Config) !*App { const app = try allocator.create(App); errdefer allocator.destroy(app); app.* = .{ .config = config, .allocator = allocator, .network = undefined, .platform = undefined, .snapshot = undefined, .app_dir_path = undefined, .telemetry = undefined, .arena_pool = undefined, }; app.network = try Network.init(allocator, config); errdefer app.network.deinit(); app.platform = try Platform.init(); errdefer app.platform.deinit(); app.snapshot = try Snapshot.load(); errdefer app.snapshot.deinit(); app.app_dir_path = getAndMakeAppDir(allocator); app.telemetry = try Telemetry.init(app, config.mode); errdefer app.telemetry.deinit(allocator); app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16); errdefer app.arena_pool.deinit(); return app; } pub fn shutdown(self: *const App) bool { return self.network.shutdown.load(.acquire); } pub fn deinit(self: *App) void { const allocator = self.allocator; if (self.app_dir_path) |app_dir_path| { allocator.free(app_dir_path); self.app_dir_path = null; } self.telemetry.deinit(allocator); self.network.deinit(); self.snapshot.deinit(); self.platform.deinit(); self.arena_pool.deinit(); allocator.destroy(self); } fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 { if (@import("builtin").is_test) { return allocator.dupe(u8, "/tmp") catch unreachable; } const app_dir_path = std.fs.getAppDataDir(allocator, "lightpanda") catch |err| { log.warn(.app, "get data dir", .{ .err = err }); return null; }; std.fs.cwd().makePath(app_dir_path) catch |err| switch (err) { error.PathAlreadyExists => return app_dir_path, else => { allocator.free(app_dir_path); log.warn(.app, "create data dir", .{ .err = err, .path = app_dir_path }); return null; }, }; return app_dir_path; } ================================================ FILE: src/ArenaPool.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArenaPool = @This(); allocator: Allocator, retain_bytes: usize, free_list_len: u16 = 0, free_list: ?*Entry = null, free_list_max: u16, entry_pool: std.heap.MemoryPool(Entry), mutex: std.Thread.Mutex = .{}, const Entry = struct { next: ?*Entry, arena: ArenaAllocator, }; pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool { return .{ .allocator = allocator, .free_list_max = free_list_max, .retain_bytes = retain_bytes, .entry_pool = .init(allocator), }; } pub fn deinit(self: *ArenaPool) void { var entry = self.free_list; while (entry) |e| { entry = e.next; e.arena.deinit(); } self.entry_pool.deinit(); } pub fn acquire(self: *ArenaPool) !Allocator { self.mutex.lock(); defer self.mutex.unlock(); if (self.free_list) |entry| { self.free_list = entry.next; self.free_list_len -= 1; return entry.arena.allocator(); } const entry = try self.entry_pool.create(); entry.* = .{ .next = null, .arena = ArenaAllocator.init(self.allocator), }; return entry.arena.allocator(); } pub fn release(self: *ArenaPool, allocator: Allocator) void { const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr)); const entry: *Entry = @fieldParentPtr("arena", arena); // Reset the arena before acquiring the lock to minimize lock hold time _ = arena.reset(.{ .retain_with_limit = self.retain_bytes }); self.mutex.lock(); defer self.mutex.unlock(); const free_list_len = self.free_list_len; if (free_list_len == self.free_list_max) { arena.deinit(); self.entry_pool.destroy(entry); return; } entry.next = self.free_list; self.free_list_len = free_list_len + 1; self.free_list = entry; } pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void { const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr)); _ = arena.reset(.{ .retain_with_limit = retain }); } const testing = std.testing; test "arena pool - basic acquire and use" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); const alloc = try pool.acquire(); const buf = try alloc.alloc(u8, 64); @memset(buf, 0xAB); try testing.expectEqual(@as(u8, 0xAB), buf[0]); pool.release(alloc); } test "arena pool - reuse entry after release" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); const alloc1 = try pool.acquire(); try testing.expectEqual(@as(u16, 0), pool.free_list_len); pool.release(alloc1); try testing.expectEqual(@as(u16, 1), pool.free_list_len); // The same entry should be returned from the free list. const alloc2 = try pool.acquire(); try testing.expectEqual(@as(u16, 0), pool.free_list_len); try testing.expectEqual(alloc1.ptr, alloc2.ptr); pool.release(alloc2); } test "arena pool - multiple concurrent arenas" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); const a1 = try pool.acquire(); const a2 = try pool.acquire(); const a3 = try pool.acquire(); // All three must be distinct arenas. try testing.expect(a1.ptr != a2.ptr); try testing.expect(a2.ptr != a3.ptr); try testing.expect(a1.ptr != a3.ptr); _ = try a1.alloc(u8, 16); _ = try a2.alloc(u8, 32); _ = try a3.alloc(u8, 48); pool.release(a1); pool.release(a2); pool.release(a3); try testing.expectEqual(@as(u16, 3), pool.free_list_len); } test "arena pool - free list respects max limit" { // Cap the free list at 1 so the second release discards its arena. var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16); defer pool.deinit(); const a1 = try pool.acquire(); const a2 = try pool.acquire(); pool.release(a1); try testing.expectEqual(@as(u16, 1), pool.free_list_len); // The free list is full; a2's arena should be destroyed, not queued. pool.release(a2); try testing.expectEqual(@as(u16, 1), pool.free_list_len); } test "arena pool - reset clears memory without releasing" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); const alloc = try pool.acquire(); const buf = try alloc.alloc(u8, 128); @memset(buf, 0xFF); // reset() frees arena memory but keeps the allocator in-flight. pool.reset(alloc, 0); // The free list must stay empty; the allocator was not released. try testing.expectEqual(@as(u16, 0), pool.free_list_len); // Allocating again through the same arena must still work. const buf2 = try alloc.alloc(u8, 64); @memset(buf2, 0x00); try testing.expectEqual(@as(u8, 0x00), buf2[0]); pool.release(alloc); } test "arena pool - deinit with entries in free list" { // Verifies that deinit properly cleans up free-listed arenas (no leaks // detected by the test allocator). var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); const a1 = try pool.acquire(); const a2 = try pool.acquire(); _ = try a1.alloc(u8, 256); _ = try a2.alloc(u8, 512); pool.release(a1); pool.release(a2); try testing.expectEqual(@as(u16, 2), pool.free_list_len); pool.deinit(); } ================================================ FILE: src/Config.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const log = @import("log.zig"); const dump = @import("browser/dump.zig"); const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; pub const RunMode = enum { help, fetch, serve, version, mcp, }; pub const MAX_LISTENERS = 16; pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // max message size // +14 for max websocket payload overhead // +140 for the max control packet that might be interleaved in a message pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140; mode: Mode, exec_name: []const u8, http_headers: HttpHeaders, const Config = @This(); pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config { var config = Config{ .mode = mode, .exec_name = exec_name, .http_headers = undefined, }; config.http_headers = try HttpHeaders.init(allocator, &config); return config; } pub fn deinit(self: *const Config, allocator: Allocator) void { self.http_headers.deinit(allocator); } pub fn tlsVerifyHost(self: *const Config) bool { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host, else => unreachable, }; } pub fn obeyRobots(self: *const Config) bool { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots, else => unreachable, }; } pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy, else => unreachable, }; } pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token, .help, .version => null, }; } pub fn httpMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10, else => unreachable, }; } pub fn httpMaxHostOpen(self: *const Config) u8 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4, else => unreachable, }; } pub fn httpConnectTimeout(self: *const Config) u31 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0, else => unreachable, }; } pub fn httpTimeout(self: *const Config) u31 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000, else => unreachable, }; } pub fn httpMaxRedirects(_: *const Config) u8 { return 10; } pub fn httpMaxResponseSize(self: *const Config) ?usize { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size, else => unreachable, }; } pub fn logLevel(self: *const Config) ?log.Level { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.log_level, else => unreachable, }; } pub fn logFormat(self: *const Config) ?log.Format { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.log_format, else => unreachable, }; } pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes, else => unreachable, }; } pub fn userAgentSuffix(self: *const Config) ?[]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix, .help, .version => null, }; } pub fn cdpTimeout(self: *const Config) usize { return switch (self.mode) { .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, else => unreachable, }; } pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{ .key_file = opts.common.web_bot_auth_key_file orelse return null, .keyid = opts.common.web_bot_auth_keyid orelse return null, .domain = opts.common.web_bot_auth_domain orelse return null, }, .help, .version => null, }; } pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, else => unreachable, }; } pub fn maxPendingConnections(self: *const Config) u31 { return switch (self.mode) { .serve => |opts| opts.cdp_max_pending_connections, else => unreachable, }; } pub const Mode = union(RunMode) { help: bool, // false when being printed because of an error fetch: Fetch, serve: Serve, version: void, mcp: Mcp, }; pub const Serve = struct { host: []const u8 = "127.0.0.1", port: u16 = 9222, timeout: u31 = 10, cdp_max_connections: u16 = 16, cdp_max_pending_connections: u16 = 128, common: Common = .{}, }; pub const Mcp = struct { common: Common = .{}, }; pub const DumpFormat = enum { html, markdown, wpt, semantic_tree, semantic_tree_text, }; pub const Fetch = struct { url: [:0]const u8, dump_mode: ?DumpFormat = null, common: Common = .{}, with_base: bool = false, with_frames: bool = false, strip: dump.Opts.Strip = .{}, }; pub const Common = struct { obey_robots: bool = false, proxy_bearer_token: ?[:0]const u8 = null, http_proxy: ?[:0]const u8 = null, http_max_concurrent: ?u8 = null, http_max_host_open: ?u8 = null, http_timeout: ?u31 = null, http_connect_timeout: ?u31 = null, http_max_response_size: ?usize = null, tls_verify_host: bool = true, log_level: ?log.Level = null, log_format: ?log.Format = null, log_filter_scopes: ?[]log.Scope = null, user_agent_suffix: ?[]const u8 = null, web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null, web_bot_auth_domain: ?[]const u8 = null, }; /// Pre-formatted HTTP headers for reuse across Http and Client. /// Must be initialized with an allocator that outlives all HTTP connections. pub const HttpHeaders = struct { const user_agent_base: [:0]const u8 = "Lightpanda/1.0"; user_agent: [:0]const u8, // User agent value (e.g. "Lightpanda/1.0") user_agent_header: [:0]const u8, proxy_bearer_header: ?[:0]const u8, pub fn init(allocator: Allocator, config: *const Config) !HttpHeaders { const user_agent: [:0]const u8 = if (config.userAgentSuffix()) |suffix| try std.fmt.allocPrintSentinel(allocator, "{s} {s}", .{ user_agent_base, suffix }, 0) else user_agent_base; errdefer if (config.userAgentSuffix() != null) allocator.free(user_agent); const user_agent_header = try std.fmt.allocPrintSentinel(allocator, "User-Agent: {s}", .{user_agent}, 0); errdefer allocator.free(user_agent_header); const proxy_bearer_header: ?[:0]const u8 = if (config.proxyBearerToken()) |token| try std.fmt.allocPrintSentinel(allocator, "Proxy-Authorization: Bearer {s}", .{token}, 0) else null; return .{ .user_agent = user_agent, .user_agent_header = user_agent_header, .proxy_bearer_header = proxy_bearer_header, }; } pub fn deinit(self: *const HttpHeaders, allocator: Allocator) void { if (self.proxy_bearer_header) |hdr| { allocator.free(hdr); } allocator.free(self.user_agent_header); if (self.user_agent.ptr != user_agent_base.ptr) { allocator.free(self.user_agent); } } }; pub fn printUsageAndExit(self: *const Config, success: bool) void { // MAX_HELP_LEN| const common_options = \\ \\--insecure_disable_tls_host_verification \\ Disables host verification on all HTTP requests. This is an \\ advanced option which should only be set if you understand \\ and accept the risk of disabling host verification. \\ \\--obey_robots \\ Fetches and obeys the robots.txt (if available) of the web pages \\ we make requests towards. \\ Defaults to false. \\ \\--http_proxy The HTTP proxy to use for all HTTP requests. \\ A username:password can be included for basic authentication. \\ Defaults to none. \\ \\--proxy_bearer_token \\ The to send for bearer authentication with the proxy \\ Proxy-Authorization: Bearer \\ \\--http_max_concurrent \\ The maximum number of concurrent HTTP requests. \\ Defaults to 10. \\ \\--http_max_host_open \\ The maximum number of open connection to a given host:port. \\ Defaults to 4. \\ \\--http_connect_timeout \\ The time, in milliseconds, for establishing an HTTP connection \\ before timing out. 0 means it never times out. \\ Defaults to 0. \\ \\--http_timeout \\ The maximum time, in milliseconds, the transfer is allowed \\ to complete. 0 means it never times out. \\ Defaults to 10000. \\ \\--http_max_response_size \\ Limits the acceptable response size for any request \\ (e.g. XHR, fetch, script loading, ...). \\ Defaults to no limit. \\ \\--log_level The log level: debug, info, warn, error or fatal. \\ Defaults to ++ (if (builtin.mode == .Debug) " info." else "warn.") ++ \\ \\ \\--log_format The log format: pretty or logfmt. \\ Defaults to ++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++ \\ \\ \\--log_filter_scopes \\ Filter out too verbose logs per scope: \\ http, unknown_prop, event, ... \\ \\--user_agent_suffix \\ Suffix to append to the Lightpanda/X.Y User-Agent \\ \\--web_bot_auth_key_file \\ Path to the Ed25519 private key PEM file. \\ \\--web_bot_auth_keyid \\ The JWK thumbprint of your public key. \\ \\--web_bot_auth_domain \\ Your domain e.g. yourdomain.com ; // MAX_HELP_LEN| const usage = \\usage: {s} command [options] [URL] \\ \\Command can be either 'fetch', 'serve', 'mcp' or 'help' \\ \\fetch command \\Fetches the specified URL \\Example: {s} fetch --dump html https://lightpanda.io/ \\ \\Options: \\--dump Dumps document to stdout. \\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'. \\ Defaults to no dump. \\ \\--strip_mode Comma separated list of tag groups to remove from dump \\ the dump. e.g. --strip_mode js,css \\ - "js" script and link[as=script, rel=preload] \\ - "ui" includes img, picture, video, css and svg \\ - "css" includes style and link[rel=stylesheet] \\ - "full" includes js, ui and css \\ \\--with_base Add a tag in dump. Defaults to false. \\ \\--with_frames Includes the contents of iframes. Defaults to false. \\ ++ common_options ++ \\ \\serve command \\Starts a websocket CDP server \\Example: {s} serve --host 127.0.0.1 --port 9222 \\ \\Options: \\--host Host of the CDP server \\ Defaults to "127.0.0.1" \\ \\--port Port of the CDP server \\ Defaults to 9222 \\ \\--timeout Inactivity timeout in seconds before disconnecting clients \\ Defaults to 10 (seconds). Limited to 604800 (1 week). \\ \\--cdp_max_connections \\ Maximum number of simultaneous CDP connections. \\ Defaults to 16. \\ \\--cdp_max_pending_connections \\ Maximum pending connections in the accept queue. \\ Defaults to 128. \\ ++ common_options ++ \\ \\mcp command \\Starts an MCP (Model Context Protocol) server over stdio \\Example: {s} mcp \\ ++ common_options ++ \\ \\version command \\Displays the version of {s} \\ \\help command \\Displays this message \\ ; std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name }); if (success) { return std.process.cleanExit(); } std.process.exit(1); } pub fn parseArgs(allocator: Allocator) !Config { var args = try std.process.argsWithAllocator(allocator); defer args.deinit(); const exec_name = try allocator.dupe(u8, std.fs.path.basename(args.next().?)); const mode_string = args.next() orelse ""; const run_mode = std.meta.stringToEnum(RunMode, mode_string) orelse blk: { const inferred_mode = inferMode(mode_string) orelse return init(allocator, exec_name, .{ .help = false }); // "command" wasn't a command but an option. We can't reset args, but // we can create a new one. Not great, but this fallback is temporary // as we transition to this command mode approach. args.deinit(); args = try std.process.argsWithAllocator(allocator); // skip the exec_name _ = args.skip(); break :blk inferred_mode; }; const mode: Mode = switch (run_mode) { .help => .{ .help = true }, .serve => .{ .serve = parseServeArgs(allocator, &args) catch return init(allocator, exec_name, .{ .help = false }) }, .fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch return init(allocator, exec_name, .{ .help = false }) }, .mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch return init(allocator, exec_name, .{ .help = false }) }, .version => .{ .version = {} }, }; return init(allocator, exec_name, mode); } fn inferMode(opt: []const u8) ?RunMode { if (opt.len == 0) { return .serve; } if (std.mem.startsWith(u8, opt, "--") == false) { return .fetch; } if (std.mem.eql(u8, opt, "--dump")) { return .fetch; } if (std.mem.eql(u8, opt, "--noscript")) { return .fetch; } if (std.mem.eql(u8, opt, "--strip_mode")) { return .fetch; } if (std.mem.eql(u8, opt, "--with_base")) { return .fetch; } if (std.mem.eql(u8, opt, "--with_frames")) { return .fetch; } if (std.mem.eql(u8, opt, "--host")) { return .serve; } if (std.mem.eql(u8, opt, "--port")) { return .serve; } if (std.mem.eql(u8, opt, "--timeout")) { return .serve; } return null; } fn parseServeArgs( allocator: Allocator, args: *std.process.ArgIterator, ) !Serve { var serve: Serve = .{}; while (args.next()) |opt| { if (std.mem.eql(u8, "--host", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--host" }); return error.InvalidArgument; }; serve.host = try allocator.dupe(u8, str); continue; } if (std.mem.eql(u8, "--port", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--port" }); return error.InvalidArgument; }; serve.port = std.fmt.parseInt(u16, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--port", .err = err }); return error.InvalidArgument; }; continue; } if (std.mem.eql(u8, "--timeout", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--timeout" }); return error.InvalidArgument; }; serve.timeout = std.fmt.parseInt(u31, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--timeout", .err = err }); return error.InvalidArgument; }; continue; } if (std.mem.eql(u8, "--cdp_max_connections", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" }); return error.InvalidArgument; }; serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_connections", .err = err }); return error.InvalidArgument; }; continue; } if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" }); return error.InvalidArgument; }; serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_pending_connections", .err = err }); return error.InvalidArgument; }; continue; } if (try parseCommonArg(allocator, opt, args, &serve.common)) { continue; } log.fatal(.app, "unknown argument", .{ .mode = "serve", .arg = opt }); return error.UnkownOption; } return serve; } fn parseMcpArgs( allocator: Allocator, args: *std.process.ArgIterator, ) !Mcp { var mcp: Mcp = .{}; while (args.next()) |opt| { if (try parseCommonArg(allocator, opt, args, &mcp.common)) { continue; } log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt }); return error.UnkownOption; } return mcp; } fn parseFetchArgs( allocator: Allocator, args: *std.process.ArgIterator, ) !Fetch { var dump_mode: ?DumpFormat = null; var with_base: bool = false; var with_frames: bool = false; var url: ?[:0]const u8 = null; var common: Common = .{}; var strip: dump.Opts.Strip = .{}; while (args.next()) |opt| { if (std.mem.eql(u8, "--dump", opt)) { var peek_args = args.*; if (peek_args.next()) |next_arg| { if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| { dump_mode = mode; _ = args.next(); } else { dump_mode = .html; } } else { dump_mode = .html; } continue; } if (std.mem.eql(u8, "--noscript", opt)) { log.warn(.app, "deprecation warning", .{ .feature = "--noscript argument", .hint = "use '--strip_mode js' instead", }); strip.js = true; continue; } if (std.mem.eql(u8, "--with_base", opt)) { with_base = true; continue; } if (std.mem.eql(u8, "--with_frames", opt)) { with_frames = true; continue; } if (std.mem.eql(u8, "--strip_mode", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--strip_mode" }); return error.InvalidArgument; }; var it = std.mem.splitScalar(u8, str, ','); while (it.next()) |part| { const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); if (std.mem.eql(u8, trimmed, "js")) { strip.js = true; } else if (std.mem.eql(u8, trimmed, "ui")) { strip.ui = true; } else if (std.mem.eql(u8, trimmed, "css")) { strip.css = true; } else if (std.mem.eql(u8, trimmed, "full")) { strip.js = true; strip.ui = true; strip.css = true; } else { log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed }); } } continue; } if (try parseCommonArg(allocator, opt, args, &common)) { continue; } if (std.mem.startsWith(u8, opt, "--")) { log.fatal(.app, "unknown argument", .{ .mode = "fetch", .arg = opt }); return error.UnkownOption; } if (url != null) { log.fatal(.app, "duplicate fetch url", .{ .help = "only 1 URL can be specified" }); return error.TooManyURLs; } url = try allocator.dupeZ(u8, opt); } if (url == null) { log.fatal(.app, "missing fetch url", .{ .help = "URL to fetch must be provided" }); return error.MissingURL; } return .{ .url = url.?, .dump_mode = dump_mode, .strip = strip, .common = common, .with_base = with_base, .with_frames = with_frames, }; } fn parseCommonArg( allocator: Allocator, opt: []const u8, args: *std.process.ArgIterator, common: *Common, ) !bool { if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) { common.tls_verify_host = false; return true; } if (std.mem.eql(u8, "--obey_robots", opt)) { common.obey_robots = true; return true; } if (std.mem.eql(u8, "--http_proxy", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" }); return error.InvalidArgument; }; common.http_proxy = try allocator.dupeZ(u8, str); return true; } if (std.mem.eql(u8, "--proxy_bearer_token", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" }); return error.InvalidArgument; }; common.proxy_bearer_token = try allocator.dupeZ(u8, str); return true; } if (std.mem.eql(u8, "--http_max_concurrent", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" }); return error.InvalidArgument; }; common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err }); return error.InvalidArgument; }; return true; } if (std.mem.eql(u8, "--http_max_host_open", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" }); return error.InvalidArgument; }; common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_host_open", .err = err }); return error.InvalidArgument; }; return true; } if (std.mem.eql(u8, "--http_connect_timeout", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" }); return error.InvalidArgument; }; common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err }); return error.InvalidArgument; }; return true; } if (std.mem.eql(u8, "--http_timeout", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" }); return error.InvalidArgument; }; common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err }); return error.InvalidArgument; }; return true; } if (std.mem.eql(u8, "--http_max_response_size", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" }); return error.InvalidArgument; }; common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| { log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_response_size", .err = err }); return error.InvalidArgument; }; return true; } if (std.mem.eql(u8, "--log_level", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--log_level" }); return error.InvalidArgument; }; common.log_level = std.meta.stringToEnum(log.Level, str) orelse blk: { if (std.mem.eql(u8, str, "error")) { break :blk .err; } log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str }); return error.InvalidArgument; }; return true; } if (std.mem.eql(u8, "--log_format", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--log_format" }); return error.InvalidArgument; }; common.log_format = std.meta.stringToEnum(log.Format, str) orelse { log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str }); return error.InvalidArgument; }; return true; } if (std.mem.eql(u8, "--log_filter_scopes", opt)) { if (builtin.mode != .Debug) { log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" }); return false; } const str = args.next() orelse { // disables the default filters common.log_filter_scopes = &.{}; return true; }; var arr: std.ArrayList(log.Scope) = .empty; var it = std.mem.splitScalar(u8, str, ','); while (it.next()) |part| { try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse { log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part }); return false; }); } common.log_filter_scopes = arr.items; return true; } if (std.mem.eql(u8, "--user_agent_suffix", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" }); return error.InvalidArgument; }; for (str) |c| { if (!std.ascii.isPrint(c)) { log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" }); return error.InvalidArgument; } } common.user_agent_suffix = try allocator.dupe(u8, str); return true; } if (std.mem.eql(u8, "--web_bot_auth_key_file", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_key_file" }); return error.InvalidArgument; }; common.web_bot_auth_key_file = try allocator.dupe(u8, str); return true; } if (std.mem.eql(u8, "--web_bot_auth_keyid", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_keyid" }); return error.InvalidArgument; }; common.web_bot_auth_keyid = try allocator.dupe(u8, str); return true; } if (std.mem.eql(u8, "--web_bot_auth_domain", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_domain" }); return error.InvalidArgument; }; common.web_bot_auth_domain = try allocator.dupe(u8, str); return true; } return false; } ================================================ FILE: src/Notification.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const lp = @import("lightpanda"); const log = @import("log.zig"); const Page = @import("browser/Page.zig"); const Transfer = @import("browser/HttpClient.zig").Transfer; const Allocator = std.mem.Allocator; const List = std.DoublyLinkedList; // Allows code to register for and emit events. // Keeps two lists // 1 - for a given event type, a linked list of all the listeners // 2 - for a given listener, a list of all it's registration // The 2nd one is so that a listener can unregister all of it's listeners // (there's currently no need for a listener to unregister only 1 or more // specific listener). // // Scoping is important. Imagine we created a global singleton registry, and our // CDP code registers for the "network_bytes_sent" event, because it needs to // send messages to the client when this happens. Our HTTP client could then // emit a "network_bytes_sent" message. It would be easy, and it would work. // That is, it would work until multiple CDP clients connect, and because // everything's just one big global, events from one CDP session would be sent // to all CDP clients. // // To avoid this, one way or another, we need scoping. We could still have // a global registry but every "register" and every "emit" has some type of // "scope". This would have a run-time cost and still require some coordination // between components to share a common scope. // // Instead, the approach that we take is to have a notification instance per // CDP connection (BrowserContext). Each CDP connection has its own notification // that is shared across all Sessions (tabs) within that connection. This ensures // proper isolation between different CDP clients while allowing a single client // to receive events from all its tabs. const Notification = @This(); // Every event type (which are hard-coded), has a list of Listeners. // When the event happens, we dispatch to those listener. event_listeners: EventListeners, // list of listeners for a specified receiver // @intFromPtr(receiver) -> [listener1, listener2, ...] // Used when `unregisterAll` is called. listeners: std.AutoHashMapUnmanaged(usize, std.ArrayList(*Listener)), allocator: Allocator, mem_pool: std.heap.MemoryPool(Listener), const EventListeners = struct { page_remove: List = .{}, page_created: List = .{}, page_navigate: List = .{}, page_navigated: List = .{}, page_network_idle: List = .{}, page_network_almost_idle: List = .{}, page_frame_created: List = .{}, http_request_fail: List = .{}, http_request_start: List = .{}, http_request_intercept: List = .{}, http_request_done: List = .{}, http_request_auth_required: List = .{}, http_response_data: List = .{}, http_response_header_done: List = .{}, }; const Events = union(enum) { page_remove: PageRemove, page_created: *Page, page_navigate: *const PageNavigate, page_navigated: *const PageNavigated, page_network_idle: *const PageNetworkIdle, page_network_almost_idle: *const PageNetworkAlmostIdle, page_frame_created: *const PageFrameCreated, http_request_fail: *const RequestFail, http_request_start: *const RequestStart, http_request_intercept: *const RequestIntercept, http_request_auth_required: *const RequestAuthRequired, http_request_done: *const RequestDone, http_response_data: *const ResponseData, http_response_header_done: *const ResponseHeaderDone, }; const EventType = std.meta.FieldEnum(Events); pub const PageRemove = struct {}; pub const PageNavigate = struct { req_id: u32, frame_id: u32, timestamp: u64, url: [:0]const u8, opts: Page.NavigateOpts, }; pub const PageNavigated = struct { req_id: u32, frame_id: u32, timestamp: u64, url: [:0]const u8, opts: Page.NavigatedOpts, }; pub const PageNetworkIdle = struct { req_id: u32, frame_id: u32, timestamp: u64, }; pub const PageNetworkAlmostIdle = struct { req_id: u32, frame_id: u32, timestamp: u64, }; pub const PageFrameCreated = struct { frame_id: u32, parent_id: u32, timestamp: u64, }; pub const RequestStart = struct { transfer: *Transfer, }; pub const RequestIntercept = struct { transfer: *Transfer, wait_for_interception: *bool, }; pub const RequestAuthRequired = struct { transfer: *Transfer, wait_for_interception: *bool, }; pub const ResponseData = struct { data: []const u8, transfer: *Transfer, }; pub const ResponseHeaderDone = struct { transfer: *Transfer, }; pub const RequestDone = struct { transfer: *Transfer, }; pub const RequestFail = struct { transfer: *Transfer, err: anyerror, }; pub fn init(allocator: Allocator) !*Notification { const notification = try allocator.create(Notification); errdefer allocator.destroy(notification); notification.* = .{ .listeners = .{}, .event_listeners = .{}, .allocator = allocator, .mem_pool = std.heap.MemoryPool(Listener).init(allocator), }; return notification; } pub fn deinit(self: *Notification) void { const allocator = self.allocator; var it = self.listeners.valueIterator(); while (it.next()) |listener| { listener.deinit(allocator); } self.listeners.deinit(allocator); self.mem_pool.deinit(); allocator.destroy(self); } pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void { var list = &@field(self.event_listeners, @tagName(event)); var listener = try self.mem_pool.create(); errdefer self.mem_pool.destroy(listener); listener.* = .{ .node = .{}, .list = list, .receiver = receiver, .event = event, .func = @ptrCast(func), .struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child), }; const allocator = self.allocator; const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver)); if (gop.found_existing == false) { gop.value_ptr.* = .{}; } try gop.value_ptr.append(allocator, listener); // we don't add this until we've successfully added the entry to // self.listeners list.append(&listener.node); } pub fn unregister(self: *Notification, comptime event: EventType, receiver: anytype) void { var listeners = self.listeners.getPtr(@intFromPtr(receiver)) orelse return; var i: usize = 0; while (i < listeners.items.len) { const listener = listeners.items[i]; if (listener.event != event) { i += 1; continue; } listener.list.remove(&listener.node); self.mem_pool.destroy(listener); _ = listeners.swapRemove(i); } if (listeners.items.len == 0) { listeners.deinit(self.allocator); const removed = self.listeners.remove(@intFromPtr(receiver)); lp.assert(removed == true, "Notification.unregister", .{ .type = event }); } } pub fn unregisterAll(self: *Notification, receiver: *anyopaque) void { var kv = self.listeners.fetchRemove(@intFromPtr(receiver)) orelse return; for (kv.value.items) |listener| { listener.list.remove(&listener.node); self.mem_pool.destroy(listener); } kv.value.deinit(self.allocator); } pub fn dispatch(self: *Notification, comptime event: EventType, data: ArgType(event)) void { if (self.listeners.count() == 0) { return; } const list = &@field(self.event_listeners, @tagName(event)); var node = list.first; while (node) |n| { const listener: *Listener = @fieldParentPtr("node", n); const func: EventFunc(event) = @ptrCast(@alignCast(listener.func)); func(listener.receiver, data) catch |err| { log.err(.app, "dispatch error", .{ .err = err, .event = event, .source = "notification", .listener = listener.struct_name, }); }; node = n.next; } } // Given an event type enum, returns the type of arg the event emits fn ArgType(comptime event: Notification.EventType) type { inline for (std.meta.fields(Notification.Events)) |f| { if (std.mem.eql(u8, f.name, @tagName(event))) { return f.type; } } unreachable; } // Given an event type enum, returns the listening function type fn EventFunc(comptime event: Notification.EventType) type { return *const fn (*anyopaque, ArgType(event)) anyerror!void; } // A listener. This is 1 receiver, with its function, and the linked list // node that goes in the appropriate EventListeners list. const Listener = struct { // the receiver of the event, i.e. the self parameter to `func` receiver: *anyopaque, // the function to call func: *const anyopaque, // For logging slightly better error struct_name: []const u8, event: Notification.EventType, // intrusive linked list node node: List.Node, // The event list this listener belongs to. // We need this in order to be able to remove the node from the list list: *List, }; const testing = std.testing; test "Notification" { var notifier = try Notification.init(testing.allocator); defer notifier.deinit(); // noop notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 4, .url = undefined, .opts = .{}, }); var tc = TestClient{}; try notifier.register(.page_navigate, &tc, TestClient.pageNavigate); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 4, .url = undefined, .opts = .{}, }); try testing.expectEqual(4, tc.page_navigate); notifier.unregisterAll(&tc); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 10, .url = undefined, .opts = .{}, }); try testing.expectEqual(4, tc.page_navigate); try notifier.register(.page_navigate, &tc, TestClient.pageNavigate); try notifier.register(.page_navigated, &tc, TestClient.pageNavigated); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 10, .url = undefined, .opts = .{}, }); notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 6, .url = undefined, .opts = .{} }); try testing.expectEqual(14, tc.page_navigate); try testing.expectEqual(6, tc.page_navigated); notifier.unregisterAll(&tc); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{}, }); notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); try testing.expectEqual(14, tc.page_navigate); try testing.expectEqual(6, tc.page_navigated); { // unregister try notifier.register(.page_navigate, &tc, TestClient.pageNavigate); try notifier.register(.page_navigated, &tc, TestClient.pageNavigated); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(1006, tc.page_navigated); notifier.unregister(.page_navigate, &tc); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(2006, tc.page_navigated); notifier.unregister(.page_navigated, &tc); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(2006, tc.page_navigated); // already unregistered, try anyways notifier.unregister(.page_navigated, &tc); notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} }); notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} }); try testing.expectEqual(114, tc.page_navigate); try testing.expectEqual(2006, tc.page_navigated); } } const TestClient = struct { page_navigate: u64 = 0, page_navigated: u64 = 0, fn pageNavigate(ptr: *anyopaque, data: *const Notification.PageNavigate) !void { const self: *TestClient = @ptrCast(@alignCast(ptr)); self.page_navigate += data.timestamp; } fn pageNavigated(ptr: *anyopaque, data: *const Notification.PageNavigated) !void { const self: *TestClient = @ptrCast(@alignCast(ptr)); self.page_navigated += data.timestamp; } }; ================================================ FILE: src/SemanticTree.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. See . const std = @import("std"); const lp = @import("lightpanda"); const log = @import("log.zig"); const isAllWhitespace = @import("string.zig").isAllWhitespace; const Page = lp.Page; const interactive = @import("browser/interactive.zig"); const CData = @import("browser/webapi/CData.zig"); const Element = @import("browser/webapi/Element.zig"); const Node = @import("browser/webapi/Node.zig"); const AXNode = @import("cdp/AXNode.zig"); const CDPNode = @import("cdp/Node.zig"); const Self = @This(); dom_node: *Node, registry: *CDPNode.Registry, page: *Page, arena: std.mem.Allocator, prune: bool = true, interactive_only: bool = false, max_depth: u32 = std.math.maxInt(u32) - 1, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { var visitor = JsonVisitor{ .jw = jw, .tree = self }; var xpath_buffer: std.ArrayList(u8) = .{}; const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| { log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; } pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 }; var xpath_buffer: std.ArrayList(u8) = .empty; const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| { log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; } const OptionData = struct { value: []const u8, text: []const u8, selected: bool, }; const NodeData = struct { id: CDPNode.Id, axn: AXNode, role: []const u8, name: ?[]const u8, value: ?[]const u8, options: ?[]OptionData = null, xpath: []const u8, is_interactive: bool, node_name: []const u8, }; fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void { if (current_depth > self.max_depth) return; // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); if (tag.isMetadata() or tag == .svg) return; // We handle options/optgroups natively inside their parents, skip them in the general walk if (tag == .datalist or tag == .option or tag == .optgroup) return; // Check visibility using the engine's checkVisibility which handles CSS display: none if (!el.checkVisibility(self.page)) { return; } if (el.is(Element.Html)) |html_el| { if (html_el.getHidden()) return; } } else if (node.is(CData.Text)) |text_node| { const text = text_node.getWholeText(); if (isAllWhitespace(text)) { return; } } else if (node._type != .document and node._type != .document_fragment) { return; } const cdp_node = try self.registry.register(node); const axn = AXNode.fromNode(node); const role = try axn.getRole(); var is_interactive = false; var value: ?[]const u8 = null; var options: ?[]OptionData = null; var node_name: []const u8 = "text"; if (node.is(Element)) |el| { node_name = el.getTagNameLower(); if (el.is(Element.Html.Input)) |input| { value = input.getValue(); if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { options = try extractDataListOptions(list_id, self.page, self.arena); } } else if (el.is(Element.Html.TextArea)) |textarea| { value = textarea.getValue(); } else if (el.is(Element.Html.Select)) |select| { value = select.getValue(self.page); options = try extractSelectOptions(el.asNode(), self.page, self.arena); } if (el.is(Element.Html)) |html_el| { if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) { is_interactive = true; } } } else if (node._type == .document or node._type == .document_fragment) { node_name = "root"; } const initial_xpath_len = xpath_buffer.items.len; try appendXPathSegment(node, xpath_buffer.writer(self.arena), index); const xpath = xpath_buffer.items; var name = try axn.getName(self.page, self.arena); const has_explicit_label = if (node.is(Element)) |el| el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null else false; const structural = isStructuralRole(role); // Filter out computed concatenated names for generic containers without explicit labels. // This prevents token bloat and ensures their StaticText children aren't incorrectly pruned. // We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text. if (name != null and structural and !has_explicit_label) { name = null; } var data = NodeData{ .id = cdp_node.id, .axn = axn, .role = role, .name = name, .value = value, .options = options, .xpath = xpath, .is_interactive = is_interactive, .node_name = node_name, }; var should_visit = true; if (self.interactive_only) { var keep = false; if (interactive.isInteractiveRole(role)) { keep = true; } else if (interactive.isContentRole(role)) { if (name != null and name.?.len > 0) { keep = true; } } else if (std.mem.eql(u8, role, "RootWebArea")) { keep = true; } else if (is_interactive) { keep = true; } if (!keep) { should_visit = false; } } else if (self.prune) { if (structural and !is_interactive and !has_explicit_label) { should_visit = false; } if (std.mem.eql(u8, role, "StaticText") and node._parent != null) { if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) { should_visit = false; } } } var did_visit = false; var should_walk_children = true; if (should_visit) { should_walk_children = try visitor.visit(node, &data); did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures } else { // If we skip the node, we must NOT tell the visitor to close it later did_visit = false; } if (should_walk_children) { // If we are printing this node normally OR skipping it and unrolling its children, // we walk the children iterator. var it = node.childrenIterator(); var tag_counts = std.StringArrayHashMap(usize).init(self.arena); while (it.next()) |child| { var tag: []const u8 = "text()"; if (child.is(Element)) |el| { tag = el.getTagNameLower(); } const gop = try tag_counts.getOrPut(tag); if (!gop.found_existing) { gop.value_ptr.* = 0; } gop.value_ptr.* += 1; try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1); } } if (did_visit) { try visitor.leave(); } xpath_buffer.shrinkRetainingCapacity(initial_xpath_len); } fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData { var options = std.ArrayListUnmanaged(OptionData){}; var it = node.childrenIterator(); while (it.next()) |child| { if (child.is(Element)) |el| { if (el.getTag() == .option) { if (el.is(Element.Html.Option)) |opt| { const text = opt.getText(page); const value = opt.getValue(page); const selected = opt.getSelected(); try options.append(arena, .{ .text = text, .value = value, .selected = selected }); } } else if (el.getTag() == .optgroup) { var group_it = child.childrenIterator(); while (group_it.next()) |group_child| { if (group_child.is(Element.Html.Option)) |opt| { const text = opt.getText(page); const value = opt.getValue(page); const selected = opt.getSelected(); try options.append(arena, .{ .text = text, .value = value, .selected = selected }); } } } } } return options.toOwnedSlice(arena); } fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Allocator) !?[]OptionData { if (page.document.getElementById(list_id, page)) |referenced_el| { if (referenced_el.getTag() == .datalist) { return try extractSelectOptions(referenced_el.asNode(), page, arena); } } return null; } fn appendXPathSegment(node: *Node, writer: anytype, index: usize) !void { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index }); } else if (node.is(CData.Text)) |_| { try std.fmt.format(writer, "/text()[{d}]", .{index}); } } const JsonVisitor = struct { jw: *std.json.Stringify, tree: Self, pub fn visit(self: *JsonVisitor, node: *Node, data: *NodeData) !bool { try self.jw.beginObject(); try self.jw.objectField("nodeId"); try self.jw.write(try std.fmt.allocPrint(self.tree.arena, "{d}", .{data.id})); try self.jw.objectField("backendDOMNodeId"); try self.jw.write(data.id); try self.jw.objectField("nodeName"); try self.jw.write(data.node_name); try self.jw.objectField("xpath"); try self.jw.write(data.xpath); if (node.is(Element)) |el| { try self.jw.objectField("nodeType"); try self.jw.write(1); try self.jw.objectField("isInteractive"); try self.jw.write(data.is_interactive); try self.jw.objectField("role"); try self.jw.write(data.role); if (data.name) |name| { if (name.len > 0) { try self.jw.objectField("name"); try self.jw.write(name); } } if (data.value) |value| { try self.jw.objectField("value"); try self.jw.write(value); } if (el._attributes) |attrs| { try self.jw.objectField("attributes"); try self.jw.beginObject(); var iter = attrs.iterator(); while (iter.next()) |attr| { try self.jw.objectField(attr._name.str()); try self.jw.write(attr._value.str()); } try self.jw.endObject(); } if (data.options) |options| { try self.jw.objectField("options"); try self.jw.beginArray(); for (options) |opt| { try self.jw.beginObject(); try self.jw.objectField("value"); try self.jw.write(opt.value); try self.jw.objectField("text"); try self.jw.write(opt.text); try self.jw.objectField("selected"); try self.jw.write(opt.selected); try self.jw.endObject(); } try self.jw.endArray(); } } else if (node.is(CData.Text)) |text_node| { try self.jw.objectField("nodeType"); try self.jw.write(3); try self.jw.objectField("nodeValue"); try self.jw.write(text_node.getWholeText()); } else { try self.jw.objectField("nodeType"); try self.jw.write(9); } try self.jw.objectField("children"); try self.jw.beginArray(); if (data.options != null) { // Signal to not walk children, as we handled them natively return false; } return true; } pub fn leave(self: *JsonVisitor) !void { try self.jw.endArray(); try self.jw.endObject(); } }; fn isStructuralRole(role: []const u8) bool { const structural_roles = std.StaticStringMap(void).initComptime(.{ .{ "none", {} }, .{ "generic", {} }, .{ "InlineTextBox", {} }, .{ "banner", {} }, .{ "navigation", {} }, .{ "main", {} }, .{ "list", {} }, .{ "listitem", {} }, .{ "table", {} }, .{ "rowgroup", {} }, .{ "row", {} }, .{ "cell", {} }, .{ "region", {} }, }); return structural_roles.has(role); } const TextVisitor = struct { writer: *std.Io.Writer, tree: Self, depth: usize, pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool { for (0..self.depth) |_| { try self.writer.writeByte(' '); } var name_to_print: ?[]const u8 = null; if (data.name) |n| { if (n.len > 0) { name_to_print = n; } } else if (node.is(CData.Text)) |text_node| { const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n"); if (trimmed.len > 0) { name_to_print = trimmed; } } const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic"); try self.writer.print("{d}", .{data.id}); if (!is_text_only) { try self.writer.print(" {s}", .{data.role}); } if (name_to_print) |n| { try self.writer.print(" '{s}'", .{n}); } if (data.value) |v| { if (v.len > 0) { try self.writer.print(" value='{s}'", .{v}); } } if (data.options) |options| { try self.writer.writeAll(" options=["); for (options, 0..) |opt, i| { if (i > 0) try self.writer.writeAll(","); try self.writer.print("'{s}'", .{opt.value}); if (opt.selected) { try self.writer.writeAll("*"); } } try self.writer.writeAll("]\n"); self.depth += 1; return false; // Native handling complete, do not walk children } try self.writer.writeByte('\n'); self.depth += 1; // If this is a leaf-like semantic node and we already have a name, // skip children to avoid redundant StaticText or noise. const is_leaf_semantic = std.mem.eql(u8, data.role, "link") or std.mem.eql(u8, data.role, "button") or std.mem.eql(u8, data.role, "heading") or std.mem.eql(u8, data.role, "code"); if (is_leaf_semantic and data.name != null and data.name.?.len > 0) { return false; } return true; } pub fn leave(self: *TextVisitor) !void { if (self.depth > 0) { self.depth -= 1; } } }; const testing = @import("testing.zig"); test "SemanticTree backendDOMNodeId" { var registry: CDPNode.Registry = .init(testing.allocator); defer registry.deinit(); var page = try testing.pageTest("cdp/registry1.html"); defer testing.reset(); defer page._session.removePage(); const st: Self = .{ .dom_node = page.window._document.asNode(), .registry = ®istry, .page = page, .arena = testing.arena_allocator, .prune = false, .interactive_only = false, .max_depth = std.math.maxInt(u32) - 1, }; const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{}); defer testing.allocator.free(json_str); try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null); } test "SemanticTree max_depth" { var registry: CDPNode.Registry = .init(testing.allocator); defer registry.deinit(); var page = try testing.pageTest("cdp/registry1.html"); defer testing.reset(); defer page._session.removePage(); const st: Self = .{ .dom_node = page.window._document.asNode(), .registry = ®istry, .page = page, .arena = testing.arena_allocator, .prune = false, .interactive_only = false, .max_depth = 1, }; var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); try st.textStringify(&aw.writer); const text_str = aw.written(); try testing.expect(std.mem.indexOf(u8, text_str, "other") == null); } ================================================ FILE: src/Server.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const lp = @import("lightpanda"); const net = std.net; const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const log = @import("log.zig"); const App = @import("App.zig"); const Config = @import("Config.zig"); const CDP = @import("cdp/cdp.zig").CDP; const Net = @import("network/websocket.zig"); const HttpClient = @import("browser/HttpClient.zig"); const Server = @This(); app: *App, allocator: Allocator, json_version_response: []const u8, // Thread management active_threads: std.atomic.Value(u32) = .init(0), clients: std.ArrayList(*Client) = .{}, client_mutex: std.Thread.Mutex = .{}, clients_pool: std.heap.MemoryPool(Client), pub fn init(app: *App, address: net.Address) !*Server { const allocator = app.allocator; const json_version_response = try buildJSONVersionResponse(allocator, address); errdefer allocator.free(json_version_response); const self = try allocator.create(Server); errdefer allocator.destroy(self); self.* = .{ .app = app, .allocator = allocator, .json_version_response = json_version_response, .clients_pool = std.heap.MemoryPool(Client).init(allocator), }; try self.app.network.bind(address, self, onAccept); log.info(.app, "server running", .{ .address = address }); return self; } pub fn shutdown(self: *Server) void { self.client_mutex.lock(); defer self.client_mutex.unlock(); for (self.clients.items) |client| { client.stop(); } } pub fn deinit(self: *Server) void { self.shutdown(); self.joinThreads(); self.clients.deinit(self.allocator); self.clients_pool.deinit(); self.allocator.free(self.json_version_response); self.allocator.destroy(self); } fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { const self: *Server = @ptrCast(@alignCast(ctx)); const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout()); self.spawnWorker(socket, timeout_ms) catch |err| { log.err(.app, "CDP spawn", .{ .err = err }); posix.close(socket); }; } fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void { defer posix.close(socket); // Client is HUGE (> 512KB) because it has a large read buffer. // V8 crashes if this is on the stack (likely related to its size). const client = self.getClient() catch |err| { log.err(.app, "CDP client create", .{ .err = err }); return; }; defer self.releaseClient(client); client.* = Client.init( socket, self.allocator, self.app, self.json_version_response, timeout_ms, ) catch |err| { log.err(.app, "CDP client init", .{ .err = err }); return; }; defer client.deinit(); self.registerClient(client); defer self.unregisterClient(client); // Check shutdown after registering to avoid missing the stop signal. // If deinit() already iterated over clients, this client won't receive stop() // and would block joinThreads() indefinitely. if (self.app.shutdown()) { return; } client.start(); } fn getClient(self: *Server) !*Client { self.client_mutex.lock(); defer self.client_mutex.unlock(); return self.clients_pool.create(); } fn releaseClient(self: *Server, client: *Client) void { self.client_mutex.lock(); defer self.client_mutex.unlock(); self.clients_pool.destroy(client); } fn registerClient(self: *Server, client: *Client) void { self.client_mutex.lock(); defer self.client_mutex.unlock(); self.clients.append(self.allocator, client) catch {}; } fn unregisterClient(self: *Server, client: *Client) void { self.client_mutex.lock(); defer self.client_mutex.unlock(); for (self.clients.items, 0..) |c, i| { if (c == client) { _ = self.clients.swapRemove(i); break; } } } fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void { if (self.app.shutdown()) { return error.ShuttingDown; } // Atomically increment active_threads only if below max_connections. // Uses CAS loop to avoid race between checking the limit and incrementing. // // cmpxchgWeak may fail for two reasons: // 1. Another thread changed the value (increment or decrement) // 2. Spurious failure on some architectures (e.g. ARM) // // We use Weak instead of Strong because we need a retry loop anyway: // if CAS fails because a thread finished (counter decreased), we should // retry rather than return an error - there may now be room for a new connection. // // On failure, cmpxchgWeak returns the actual value, which we reuse to avoid // an extra load on the next iteration. const max_connections = self.app.config.maxConnections(); var current = self.active_threads.load(.monotonic); while (current < max_connections) { current = self.active_threads.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break; } else { return error.MaxThreadsReached; } errdefer _ = self.active_threads.fetchSub(1, .monotonic); const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket, timeout_ms }); thread.detach(); } fn runWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) void { defer _ = self.active_threads.fetchSub(1, .monotonic); handleConnection(self, socket, timeout_ms); } fn joinThreads(self: *Server) void { while (self.active_threads.load(.monotonic) > 0) { std.Thread.sleep(10 * std.time.ns_per_ms); } } // Handle exactly one TCP connection. pub const Client = struct { // The client is initially serving HTTP requests but, under normal circumstances // should eventually be upgraded to a websocket connections mode: union(enum) { http: void, cdp: CDP, }, allocator: Allocator, app: *App, http: *HttpClient, ws: Net.WsConnection, fn init( socket: posix.socket_t, allocator: Allocator, app: *App, json_version_response: []const u8, timeout_ms: u32, ) !Client { var ws = try Net.WsConnection.init(socket, allocator, json_version_response, timeout_ms); errdefer ws.deinit(); if (log.enabled(.app, .info)) { const client_address = ws.getAddress() catch null; log.info(.app, "client connected", .{ .ip = client_address }); } const http = try HttpClient.init(allocator, &app.network); errdefer http.deinit(); return .{ .allocator = allocator, .app = app, .http = http, .ws = ws, .mode = .{ .http = {} }, }; } fn stop(self: *Client) void { switch (self.mode) { .http => {}, .cdp => |*cdp| { cdp.browser.env.terminate(); self.ws.sendClose(); }, } self.ws.shutdown(); } fn deinit(self: *Client) void { switch (self.mode) { .cdp => |*cdp| cdp.deinit(), .http => {}, } self.ws.deinit(); self.http.deinit(); } fn start(self: *Client) void { const http = self.http; http.cdp_client = .{ .socket = self.ws.socket, .ctx = self, .blocking_read_start = Client.blockingReadStart, .blocking_read = Client.blockingRead, .blocking_read_end = Client.blockingReadStop, }; defer http.cdp_client = null; self.httpLoop(http) catch |err| { log.err(.app, "CDP client loop", .{ .err = err }); }; } fn httpLoop(self: *Client, http: *HttpClient) !void { lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{}); while (true) { const status = http.tick(self.ws.timeout_ms) catch |err| { log.err(.app, "http tick", .{ .err = err }); return; }; if (status != .cdp_socket) { log.info(.app, "CDP timeout", .{}); return; } if (self.readSocket() == false) { return; } if (self.mode == .cdp) { break; } } var cdp = &self.mode.cdp; var last_message = milliTimestamp(.monotonic); var ms_remaining = self.ws.timeout_ms; while (true) { switch (cdp.pageWait(ms_remaining)) { .cdp_socket => { if (self.readSocket() == false) { return; } last_message = milliTimestamp(.monotonic); ms_remaining = self.ws.timeout_ms; }, .no_page => { const status = http.tick(ms_remaining) catch |err| { log.err(.app, "http tick", .{ .err = err }); return; }; if (status != .cdp_socket) { log.info(.app, "CDP timeout", .{}); return; } if (self.readSocket() == false) { return; } last_message = milliTimestamp(.monotonic); ms_remaining = self.ws.timeout_ms; }, .done => { const now = milliTimestamp(.monotonic); const elapsed = now - last_message; if (elapsed >= ms_remaining) { log.info(.app, "CDP timeout", .{}); return; } ms_remaining -= @intCast(elapsed); last_message = now; }, } } } fn blockingReadStart(ctx: *anyopaque) bool { const self: *Client = @ptrCast(@alignCast(ctx)); self.ws.setBlocking(true) catch |err| { log.warn(.app, "CDP blockingReadStart", .{ .err = err }); return false; }; return true; } fn blockingRead(ctx: *anyopaque) bool { const self: *Client = @ptrCast(@alignCast(ctx)); return self.readSocket(); } fn blockingReadStop(ctx: *anyopaque) bool { const self: *Client = @ptrCast(@alignCast(ctx)); self.ws.setBlocking(false) catch |err| { log.warn(.app, "CDP blockingReadStop", .{ .err = err }); return false; }; return true; } fn readSocket(self: *Client) bool { const n = self.ws.read() catch |err| { log.warn(.app, "CDP read", .{ .err = err }); return false; }; if (n == 0) { log.info(.app, "CDP disconnect", .{}); return false; } return self.processData() catch false; } fn processData(self: *Client) !bool { switch (self.mode) { .cdp => |*cdp| return self.processWebsocketMessage(cdp), .http => return self.processHTTPRequest(), } } fn processHTTPRequest(self: *Client) !bool { lp.assert(self.ws.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.ws.reader.pos }); const request = self.ws.reader.buf[0..self.ws.reader.len]; if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) { self.writeHTTPErrorResponse(413, "Request too large"); return error.RequestTooLarge; } // we're only expecting [body-less] GET requests. if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) { // we need more data, put any more data here return true; } // the next incoming data can go to the front of our buffer defer self.ws.reader.len = 0; return self.handleHTTPRequest(request) catch |err| { switch (err) { error.NotFound => self.writeHTTPErrorResponse(404, "Not found"), error.InvalidRequest => self.writeHTTPErrorResponse(400, "Invalid request"), error.InvalidProtocol => self.writeHTTPErrorResponse(400, "Invalid HTTP protocol"), error.MissingHeaders => self.writeHTTPErrorResponse(400, "Missing required header"), error.InvalidUpgradeHeader => self.writeHTTPErrorResponse(400, "Unsupported upgrade type"), error.InvalidVersionHeader => self.writeHTTPErrorResponse(400, "Invalid websocket version"), error.InvalidConnectionHeader => self.writeHTTPErrorResponse(400, "Invalid connection header"), else => { log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] }); self.writeHTTPErrorResponse(500, "Internal Server Error"); }, } return err; }; } fn handleHTTPRequest(self: *Client, request: []u8) !bool { if (request.len < 18) { // 18 is [generously] the smallest acceptable HTTP request return error.InvalidRequest; } if (std.mem.eql(u8, request[0..4], "GET ") == false) { return error.NotFound; } const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse { return error.InvalidRequest; }; const url = request[4..url_end]; if (std.mem.eql(u8, url, "/")) { try self.upgradeConnection(request); return true; } if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) { try self.ws.send(self.ws.json_version_response); // Chromedp (a Go driver) does an http request to /json/version // then to / (websocket upgrade) using a different connection. // Since we only allow 1 connection at a time, the 2nd one (the // websocket upgrade) blocks until the first one times out. // We can avoid that by closing the connection. json_version_response // has a Connection: Close header too. self.ws.shutdown(); return false; } return error.NotFound; } fn upgradeConnection(self: *Client, request: []u8) !void { try self.ws.upgrade(request); self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) }; } fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void { self.ws.sendHttpError(status, body); } fn processWebsocketMessage(self: *Client, cdp: *CDP) !bool { return self.ws.processMessages(cdp); } pub fn sendAllocator(self: *Client) Allocator { return self.ws.send_arena.allocator(); } pub fn sendJSON(self: *Client, message: anytype, opts: std.json.Stringify.Options) !void { return self.ws.sendJSON(message, opts); } pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void { return self.ws.sendJSONRaw(buf); } }; // Utils // -------- fn buildJSONVersionResponse( allocator: Allocator, address: net.Address, ) ![]const u8 { const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}"; const body_len = std.fmt.count(body_format, .{address}); // We send a Connection: Close (and actually close the connection) // because chromedp (Go driver) sends a request to /json/version and then // does an upgrade request, on a different connection. Since we only allow // 1 connection at a time, the upgrade connection doesn't proceed until we // timeout the /json/version. So, instead of waiting for that, we just // always close HTTP requests. const response_format = "HTTP/1.1 200 OK\r\n" ++ "Content-Length: {d}\r\n" ++ "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ body_format; return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address }); } pub const timestamp = @import("datetime.zig").timestamp; pub const milliTimestamp = @import("datetime.zig").milliTimestamp; const testing = std.testing; test "server: buildJSONVersionResponse" { const address = try net.Address.parseIp4("127.0.0.1", 9001); const res = try buildJSONVersionResponse(testing.allocator, address); defer testing.allocator.free(res); try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++ "Content-Length: 48\r\n" ++ "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res); } test "Client: http invalid request" { var c = try createTestClient(); defer c.deinit(); const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n"); try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++ "Connection: Close\r\n" ++ "Content-Length: 17\r\n\r\n" ++ "Request too large", res); } test "Client: http invalid handshake" { try assertHTTPError( 400, "Invalid request", "\r\n\r\n", ); try assertHTTPError( 404, "Not found", "GET /over/9000 HTTP/1.1\r\n\r\n", ); try assertHTTPError( 404, "Not found", "POST / HTTP/1.1\r\n\r\n", ); try assertHTTPError( 400, "Invalid HTTP protocol", "GET / HTTP/1.0\r\n\r\n", ); try assertHTTPError( 400, "Missing required header", "GET / HTTP/1.1\r\n\r\n", ); try assertHTTPError( 400, "Missing required header", "GET / HTTP/1.1\r\nConnection: upgrade\r\n\r\n", ); try assertHTTPError( 400, "Missing required header", "GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\n\r\n", ); try assertHTTPError( 400, "Missing required header", "GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\n\r\n", ); } test "Client: http valid handshake" { var c = try createTestClient(); defer c.deinit(); const request = "GET / HTTP/1.1\r\n" ++ "Connection: upgrade\r\n" ++ "Upgrade: websocket\r\n" ++ "sec-websocket-version:13\r\n" ++ "sec-websocket-key: this is my key\r\n" ++ "Custom: Header-Value\r\n\r\n"; const res = try c.httpRequest(request); try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++ "Upgrade: websocket\r\n" ++ "Connection: upgrade\r\n" ++ "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); } test "Client: read invalid websocket message" { // 131 = 128 (fin) | 3 where 3 isn't a valid type try assertWebSocketError( 1002, &.{ 131, 128, 'm', 'a', 's', 'k' }, ); for ([_]u8{ 16, 32, 64 }) |rsv| { // none of the reserve flags should be set try assertWebSocketError( 1002, &.{ rsv, 128, 'm', 'a', 's', 'k' }, ); // as a bitmask try assertWebSocketError( 1002, &.{ rsv + 4, 128, 'm', 'a', 's', 'k' }, ); } // client->server messages must be masked try assertWebSocketError( 1002, &.{ 129, 1, 'a' }, ); // control types (ping/ping/close) can't be > 125 bytes for ([_]u8{ 136, 137, 138 }) |op| { try assertWebSocketError( 1002, &.{ op, 254, 1, 1 }, ); } // length of message is 0000 0810, i.e: 1024 * 512 + 265 try assertWebSocketError(1009, &.{ 129, 255, 0, 0, 0, 0, 0, 8, 1, 0, 'm', 'a', 's', 'k' }); // continuation type message must come after a normal message // even when not a fin frame try assertWebSocketError( 1002, &.{ 0, 129, 'm', 'a', 's', 'k', 'd' }, ); // continuation type message must come after a normal message // even as a fin frame try assertWebSocketError( 1002, &.{ 128, 129, 'm', 'a', 's', 'k', 'd' }, ); // text (non-fin) - text (non-fin) try assertWebSocketError( 1002, &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 1, 128, 'k', 's', 'a', 'm' }, ); // text (non-fin) - text (fin) should always been continuation after non-fin try assertWebSocketError( 1002, &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 129, 128, 'k', 's', 'a', 'm' }, ); // close must be fin try assertWebSocketError( 1002, &.{ 8, 129, 'm', 'a', 's', 'k', 'd', }, ); // ping must be fin try assertWebSocketError( 1002, &.{ 9, 129, 'm', 'a', 's', 'k', 'd', }, ); // pong must be fin try assertWebSocketError( 1002, &.{ 10, 129, 'm', 'a', 's', 'k', 'd', }, ); } test "Client: ping reply" { try assertWebSocketMessage( // fin | pong, len &.{ 138, 0 }, // fin | ping, masked | len, 4-byte mask &.{ 137, 128, 0, 0, 0, 0 }, ); try assertWebSocketMessage( // fin | pong, len, payload &.{ 138, 5, 100, 96, 97, 109, 104 }, // fin | ping, masked | len, 4-byte mask, 5 byte payload &.{ 137, 133, 0, 5, 7, 10, 100, 101, 102, 103, 104 }, ); } test "Client: close message" { try assertWebSocketMessage( // fin | close, len, close code (normal) &.{ 136, 2, 3, 232 }, // fin | close, masked | len, 4-byte mask &.{ 136, 128, 0, 0, 0, 0 }, ); } test "server: 404" { var c = try createTestClient(); defer c.deinit(); const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n"); try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++ "Connection: Close\r\n" ++ "Content-Length: 9\r\n\r\n" ++ "Not found", res); } test "server: get /json/version" { const expected_response = "HTTP/1.1 200 OK\r\n" ++ "Content-Length: 48\r\n" ++ "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"}"; { // twice on the same connection var c = try createTestClient(); defer c.deinit(); const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); try testing.expectEqualStrings(expected_response, res1); } { // again on a new connection var c = try createTestClient(); defer c.deinit(); const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); try testing.expectEqualStrings(expected_response, res1); } } fn assertHTTPError( comptime expected_status: u16, comptime expected_body: []const u8, input: []const u8, ) !void { var c = try createTestClient(); defer c.deinit(); const res = try c.httpRequest(input); const expected_response = std.fmt.comptimePrint( "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", .{ expected_status, expected_body.len, expected_body }, ); try testing.expectEqualStrings(expected_response, res); } fn assertWebSocketError(close_code: u16, input: []const u8) !void { var c = try createTestClient(); defer c.deinit(); try c.handshake(); try c.stream.writeAll(input); const msg = try c.readWebsocketMessage() orelse return error.NoMessage; defer if (msg.cleanup_fragment) { c.reader.cleanup(); }; try testing.expectEqual(.close, msg.type); try testing.expectEqual(2, msg.data.len); try testing.expectEqual(close_code, std.mem.readInt(u16, msg.data[0..2], .big)); } fn assertWebSocketMessage(expected: []const u8, input: []const u8) !void { var c = try createTestClient(); defer c.deinit(); try c.handshake(); try c.stream.writeAll(input); const msg = try c.readWebsocketMessage() orelse return error.NoMessage; defer if (msg.cleanup_fragment) { c.reader.cleanup(); }; const actual = c.reader.buf[0 .. msg.data.len + 2]; try testing.expectEqualSlices(u8, expected, actual); } const MockCDP = struct { messages: std.ArrayList([]const u8) = .{}, allocator: Allocator = testing.allocator, fn init(_: Allocator, client: anytype) MockCDP { _ = client; return .{}; } fn deinit(self: *MockCDP) void { const allocator = self.allocator; for (self.messages.items) |msg| { allocator.free(msg); } self.messages.deinit(allocator); } fn handleMessage(self: *MockCDP, message: []const u8) bool { const owned = self.allocator.dupe(u8, message) catch unreachable; self.messages.append(self.allocator, owned) catch unreachable; return true; } }; fn createTestClient() !TestClient { const address = std.net.Address.initIp4([_]u8{ 127, 0, 0, 1 }, 9583); const stream = try std.net.tcpConnectToAddress(address); const timeout = std.mem.toBytes(posix.timeval{ .sec = 2, .usec = 0, }); try posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout); try posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout); return .{ .stream = stream, .reader = .{ .allocator = testing.allocator, .buf = try testing.allocator.alloc(u8, 1024 * 16), }, }; } const TestClient = struct { stream: std.net.Stream, buf: [1024]u8 = undefined, reader: Net.Reader(false), fn deinit(self: *TestClient) void { self.stream.close(); self.reader.deinit(); } fn httpRequest(self: *TestClient, req: []const u8) ![]const u8 { try self.stream.writeAll(req); var pos: usize = 0; var total_length: ?usize = null; while (true) { pos += try self.stream.read(self.buf[pos..]); if (pos == 0) { return error.NoMoreData; } const response = self.buf[0..pos]; if (total_length == null) { const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse continue; const header = response[0 .. header_end + 4]; const cl = blk: { const cl_header = "Content-Length: "; const start = (std.mem.indexOf(u8, header, cl_header) orelse { break :blk 0; }) + cl_header.len; const end = std.mem.indexOfScalarPos(u8, header, start, '\r') orelse { return error.InvalidContentLength; }; break :blk std.fmt.parseInt(usize, header[start..end], 10) catch { return error.InvalidContentLength; }; }; total_length = cl + header.len; } if (total_length) |tl| { if (pos == tl) { return response; } if (pos > tl) { return error.DataExceedsContentLength; } } } } fn handshake(self: *TestClient) !void { const request = "GET / HTTP/1.1\r\n" ++ "Connection: upgrade\r\n" ++ "Upgrade: websocket\r\n" ++ "sec-websocket-version:13\r\n" ++ "sec-websocket-key: this is my key\r\n" ++ "Custom: Header-Value\r\n\r\n"; const res = try self.httpRequest(request); try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++ "Upgrade: websocket\r\n" ++ "Connection: upgrade\r\n" ++ "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); } fn readWebsocketMessage(self: *TestClient) !?Net.Message { while (true) { const n = try self.stream.read(self.reader.readBuf()); if (n == 0) { return error.Closed; } self.reader.len += n; if (try self.reader.next()) |msg| { return msg; } } } }; ================================================ FILE: src/Sighandler.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //! This structure processes operating system signals (SIGINT, SIGTERM) //! and runs callbacks to clean up the system gracefully. //! //! The structure does not clear the memory allocated in the arena, //! clear the entire arena when exiting the program. const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const lp = @import("lightpanda"); const log = lp.log; const SigHandler = @This(); arena: Allocator, sigset: std.posix.sigset_t = undefined, handle_thread: ?std.Thread = null, attempt: u32 = 0, listeners: std.ArrayList(Listener) = .empty, pub const Listener = struct { args: []const u8, start: *const fn (context: *const anyopaque) void, }; pub fn install(self: *SigHandler) !void { // Block SIGINT and SIGTERM for the current thread and all created from it self.sigset = std.posix.sigemptyset(); std.posix.sigaddset(&self.sigset, std.posix.SIG.INT); std.posix.sigaddset(&self.sigset, std.posix.SIG.TERM); std.posix.sigaddset(&self.sigset, std.posix.SIG.QUIT); std.posix.sigprocmask(std.posix.SIG.BLOCK, &self.sigset, null); self.handle_thread = try std.Thread.spawn(.{ .allocator = self.arena }, SigHandler.sighandle, .{self}); self.handle_thread.?.detach(); } pub fn on(self: *SigHandler, func: anytype, args: std.meta.ArgsTuple(@TypeOf(func))) !void { assert(@typeInfo(@TypeOf(func)).@"fn".return_type.? == void); const Args = @TypeOf(args); const TypeErased = struct { fn start(context: *const anyopaque) void { const args_casted: *const Args = @ptrCast(@alignCast(context)); @call(.auto, func, args_casted.*); } }; const buffer = try self.arena.alignedAlloc(u8, .of(Args), @sizeOf(Args)); errdefer self.arena.free(buffer); const bytes: []const u8 = @ptrCast((&args)[0..1]); @memcpy(buffer, bytes); try self.listeners.append(self.arena, .{ .args = buffer, .start = TypeErased.start, }); } fn sighandle(self: *SigHandler) noreturn { while (true) { var sig: c_int = 0; const rc = std.c.sigwait(&self.sigset, &sig); if (rc != 0) { log.err(.app, "Unable to process signal {}", .{rc}); std.process.exit(1); } switch (sig) { std.posix.SIG.INT, std.posix.SIG.TERM => { if (self.attempt > 1) { std.process.exit(1); } self.attempt += 1; log.info(.app, "Received termination signal...", .{}); for (self.listeners.items) |*item| { item.start(item.args.ptr); } continue; }, else => continue, } } } ================================================ FILE: src/TestHTTPServer.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const URL = @import("browser/URL.zig"); const TestHTTPServer = @This(); shutdown: std.atomic.Value(bool), listener: ?std.net.Server, handler: Handler, const Handler = *const fn (req: *std.http.Server.Request) anyerror!void; pub fn init(handler: Handler) TestHTTPServer { return .{ .shutdown = .init(true), .listener = null, .handler = handler, }; } pub fn deinit(self: *TestHTTPServer) void { self.listener = null; } pub fn stop(self: *TestHTTPServer) void { self.shutdown.store(true, .release); if (self.listener) |*listener| { switch (@import("builtin").target.os.tag) { .linux => std.posix.shutdown(listener.stream.handle, .recv) catch {}, else => std.posix.close(listener.stream.handle), } } } pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void { const address = try std.net.Address.parseIp("127.0.0.1", 9582); self.listener = try address.listen(.{ .reuse_address = true }); var listener = &self.listener.?; self.shutdown.store(false, .release); wg.finish(); while (true) { const conn = listener.accept() catch |err| { if (self.shutdown.load(.acquire) or err == error.SocketNotListening) { return; } return err; }; const thrd = try std.Thread.spawn(.{}, handleConnection, .{ self, conn }); thrd.detach(); } } fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !void { defer conn.stream.close(); var req_buf: [2048]u8 = undefined; var conn_reader = conn.stream.reader(&req_buf); var conn_writer = conn.stream.writer(&req_buf); var http_server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface); while (true) { var req = http_server.receiveHead() catch |err| switch (err) { error.ReadFailed => continue, error.HttpConnectionClosing => continue, else => { std.debug.print("Test HTTP Server error: {}\n", .{err}); return err; }, }; self.handler(&req) catch |err| { std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err }); try req.respond("server error", .{ .status = .internal_server_error }); return; }; } } pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void { var url_buf: [1024]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&url_buf); const unescaped_file_path = try URL.unescape(fba.allocator(), file_path); var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) { error.FileNotFound => return req.respond("server error", .{ .status = .not_found }), else => return err, }; defer file.close(); const stat = try file.stat(); var send_buffer: [4096]u8 = undefined; var res = try req.respondStreaming(&send_buffer, .{ .content_length = stat.size, .respond_options = .{ .extra_headers = &.{ .{ .name = "content-type", .value = getContentType(file_path) }, }, }, }); var read_buffer: [4096]u8 = undefined; var reader = file.reader(&read_buffer); _ = try res.writer.sendFileAll(&reader, .unlimited); try res.writer.flush(); try res.end(); } fn getContentType(file_path: []const u8) []const u8 { if (std.mem.endsWith(u8, file_path, ".js")) { return "application/json"; } if (std.mem.endsWith(u8, file_path, ".html")) { return "text/html"; } if (std.mem.endsWith(u8, file_path, ".htm")) { return "text/html"; } if (std.mem.endsWith(u8, file_path, ".xml")) { // some wpt tests do this return "text/xml"; } if (std.mem.endsWith(u8, file_path, ".mjs")) { // mjs are ECMAScript modules return "application/json"; } std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path}); return "text/html"; } ================================================ FILE: src/browser/Browser.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const js = @import("js/js.zig"); const log = @import("../log.zig"); const App = @import("../App.zig"); const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; const IS_DEBUG = @import("builtin").mode == .Debug; const Session = @import("Session.zig"); const Notification = @import("../Notification.zig"); // Browser is an instance of the browser. // You can create multiple browser instances. // A browser contains only one session. const Browser = @This(); env: js.Env, app: *App, session: ?Session, allocator: Allocator, arena_pool: *ArenaPool, http_client: *HttpClient, const InitOpts = struct { env: js.Env.InitOpts = .{}, http_client: *HttpClient, }; pub fn init(app: *App, opts: InitOpts) !Browser { const allocator = app.allocator; var env = try js.Env.init(app, opts.env); errdefer env.deinit(); return .{ .app = app, .env = env, .session = null, .allocator = allocator, .arena_pool = &app.arena_pool, .http_client = opts.http_client, }; } pub fn deinit(self: *Browser) void { self.closeSession(); self.env.deinit(); } pub fn newSession(self: *Browser, notification: *Notification) !*Session { self.closeSession(); self.session = @as(Session, undefined); const session = &self.session.?; try Session.init(session, self, notification); return session; } pub fn closeSession(self: *Browser) void { if (self.session) |*session| { session.deinit(); self.session = null; self.env.memoryPressureNotification(.critical); } } pub fn runMicrotasks(self: *Browser) void { self.env.runMicrotasks(); } pub fn runMacrotasks(self: *Browser) !void { const env = &self.env; try self.env.runMacrotasks(); env.pumpMessageLoop(); // either of the above could have queued more microtasks env.runMicrotasks(); } pub fn hasBackgroundTasks(self: *Browser) bool { return self.env.hasBackgroundTasks(); } pub fn waitForBackgroundTasks(self: *Browser) void { self.env.waitForBackgroundTasks(); } pub fn msToNextMacrotask(self: *Browser) ?u64 { return self.env.msToNextMacrotask(); } pub fn msTo(self: *Browser) bool { return self.env.hasBackgroundTasks(); } pub fn runIdleTasks(self: *const Browser) void { self.env.runIdleTasks(); } ================================================ FILE: src/browser/EventManager.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const builtin = @import("builtin"); const log = @import("../log.zig"); const String = @import("../string.zig").String; const js = @import("js/js.zig"); const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); const Allocator = std.mem.Allocator; const IS_DEBUG = builtin.mode == .Debug; const EventKey = struct { event_target: usize, type_string: String, }; const EventKeyContext = struct { pub fn hash(_: @This(), key: EventKey) u64 { var hasher = std.hash.Wyhash.init(0); hasher.update(std.mem.asBytes(&key.event_target)); hasher.update(key.type_string.str()); return hasher.final(); } pub fn eql(_: @This(), a: EventKey, b: EventKey) bool { return a.event_target == b.event_target and a.type_string.eql(b.type_string); } }; pub const EventManager = @This(); page: *Page, arena: Allocator, // Used as an optimization in Page._documentIsComplete. If we know there are no // 'load' listeners in the document, we can skip dispatching the per-resource // 'load' event (e.g. amazon product page has no listener and ~350 resources) has_dom_load_listener: bool, listener_pool: std.heap.MemoryPool(Listener), ignore_list: std.ArrayList(*Listener), list_pool: std.heap.MemoryPool(std.DoublyLinkedList), lookup: std.HashMapUnmanaged( EventKey, *std.DoublyLinkedList, EventKeyContext, std.hash_map.default_max_load_percentage, ), dispatch_depth: usize, deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }), pub fn init(arena: Allocator, page: *Page) EventManager { return .{ .page = page, .lookup = .{}, .arena = arena, .ignore_list = .{}, .list_pool = .init(arena), .listener_pool = .init(arena), .dispatch_depth = 0, .deferred_removals = .{}, .has_dom_load_listener = false, }; } pub const RegisterOptions = struct { once: bool = false, capture: bool = false, passive: bool = false, signal: ?*@import("webapi/AbortSignal.zig") = null, }; pub const Callback = union(enum) { function: js.Function, object: js.Object, }; pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void { if (comptime IS_DEBUG) { log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target.toString() }); } // If a signal is provided and already aborted, don't register the listener if (opts.signal) |signal| { if (signal.getAborted()) { return; } } // Allocate the type string we'll use in both listener and key const type_string = try String.init(self.arena, typ, .{}); if (type_string.eql(comptime .wrap("load")) and target._type == .node) { self.has_dom_load_listener = true; } const gop = try self.lookup.getOrPut(self.arena, .{ .type_string = type_string, .event_target = @intFromPtr(target), }); if (gop.found_existing) { // check for duplicate callbacks already registered var node = gop.value_ptr.*.first; while (node) |n| { const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); const is_duplicate = switch (callback) { .object => |obj| listener.function.eqlObject(obj), .function => |func| listener.function.eqlFunction(func), }; if (is_duplicate and listener.capture == opts.capture) { return; } node = n.next; } } else { gop.value_ptr.* = try self.list_pool.create(); gop.value_ptr.*.* = .{}; } const func = switch (callback) { .function => |f| Function{ .value = try f.persist() }, .object => |o| Function{ .object = try o.persist() }, }; const listener = try self.listener_pool.create(); listener.* = .{ .node = .{}, .once = opts.once, .capture = opts.capture, .passive = opts.passive, .function = func, .signal = opts.signal, .typ = type_string, }; // append the listener to the list of listeners for this target gop.value_ptr.*.append(&listener.node); // Track load listeners for script execution ignore list if (type_string.eql(comptime .wrap("load"))) { try self.ignore_list.append(self.arena, listener); } } pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void { const list = self.lookup.get(.{ .type_string = .wrap(typ), .event_target = @intFromPtr(target), }) orelse return; if (findListener(list, callback, use_capture)) |listener| { self.removeListener(list, listener); } } pub fn clearIgnoreList(self: *EventManager) void { self.ignore_list.clearRetainingCapacity(); } // Dispatching can be recursive from the compiler's point of view, so we need to // give it an explicit error set so that other parts of the code can use and // inferred error. const DispatchError = error{ OutOfMemory, StringTooLarge, JSExecCallback, CompilationError, ExecutionError, JsException, }; pub const DispatchOpts = struct { // A "load" event triggered by a script (in ScriptManager) should not trigger // a "load" listener added within that script. Therefore, any "load" listener // that we add go into an ignore list until after the script finishes executing. // The ignore list is only checked when apply_ignore == true, which is only // set by the ScriptManager when raising the script's "load" event. apply_ignore: bool = false, }; pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void { return self.dispatchOpts(target, event, .{}); } pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void { event.acquireRef(); defer event.deinit(false, self.page._session); if (comptime IS_DEBUG) { log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); } switch (target._type) { .node => |node| try self.dispatchNode(node, event, opts), else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }), } } // There are a lot of events that can be attached via addEventListener or as // a property, like the XHR events, or window.onload. You might think that the // property is just a shortcut for calling addEventListener, but they are distinct. // An event set via property cannot be removed by removeEventListener. If you // set both the property and add a listener, they both execute. const DispatchDirectOptions = struct { context: []const u8, inject_target: bool = true, }; // Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with // property handlers. No propagation - just calls the handler and registered listeners. // Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void { const page = self.page; // Set window.event to the currently dispatching event (WHATWG spec) const window = page.window; const prev_event = window._current_event; window._current_event = event; defer window._current_event = prev_event; event.acquireRef(); defer event.deinit(false, page._session); if (comptime IS_DEBUG) { log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context }); } if (comptime opts.inject_target) { event._target = target; event._dispatch_target = target; // Store original target for composedPath() } var was_dispatched = false; var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer { ls.local.runMicrotasks(); ls.deinit(); } if (getFunction(handler, &ls.local)) |func| { event._current_target = target; if (func.callWithThis(void, target, .{event})) { was_dispatched = true; } else |err| { // a non-JS error log.warn(.event, opts.context, .{ .err = err }); } } // listeners reigstered via addEventListener const list = self.lookup.get(.{ .event_target = @intFromPtr(target), .type_string = event._type_string, }) orelse return; // This is a slightly simplified version of what you'll find in dispatchPhase // It is simpler because, for direct dispatching, we know there's no ancestors // and only the single target phase. // Track dispatch depth for deferred removal self.dispatch_depth += 1; defer { const dispatch_depth = self.dispatch_depth; // Only destroy deferred listeners when we exit the outermost dispatch if (dispatch_depth == 1) { for (self.deferred_removals.items) |removal| { removal.list.remove(&removal.listener.node); self.listener_pool.destroy(removal.listener); } self.deferred_removals.clearRetainingCapacity(); } else { self.dispatch_depth = dispatch_depth - 1; } } // Use the last listener in the list as sentinel - listeners added during dispatch will be after it const last_node = list.last orelse return; const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node)); // Iterate through the list, stopping after we've encountered the last_listener var node = list.first; var is_done = false; while (node) |n| { if (is_done) { break; } const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); is_done = (listener == last_listener); node = n.next; // Skip removed listeners if (listener.removed) { continue; } // If the listener has an aborted signal, remove it and skip if (listener.signal) |signal| { if (signal.getAborted()) { self.removeListener(list, listener); continue; } } // Remove "once" listeners BEFORE calling them so nested dispatches don't see them if (listener.once) { self.removeListener(list, listener); } was_dispatched = true; event._current_target = target; switch (listener.function) { .value => |value| try ls.toLocal(value).callWithThis(void, target, .{event}), .string => |string| { const str = try page.call_arena.dupeZ(u8, string.str()); try ls.local.eval(str, null); }, .object => |obj_global| { const obj = ls.toLocal(obj_global); if (try obj.getFunction("handleEvent")) |handleEvent| { try handleEvent.callWithThis(void, obj, .{event}); } }, } if (event._stop_immediate_propagation) { return; } } } fn getFunction(handler: anytype, local: *const js.Local) ?js.Function { const T = @TypeOf(handler); const ti = @typeInfo(T); if (ti == .null) { return null; } if (ti == .optional) { return getFunction(handler orelse return null, local); } return switch (T) { js.Function => handler, js.Function.Temp => local.toLocal(handler), js.Function.Global => local.toLocal(handler), else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"), }; } /// Check if there are any listeners for a direct dispatch (non-DOM target). /// Use this to avoid creating an event when there are no listeners. pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool { if (hasHandler(handler)) { return true; } return self.lookup.get(.{ .event_target = @intFromPtr(target), .type_string = .wrap(typ), }) != null; } fn hasHandler(handler: anytype) bool { const ti = @typeInfo(@TypeOf(handler)); if (ti == .null) { return false; } if (ti == .optional) { return handler != null; } return true; } fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void { const ShadowRoot = @import("webapi/ShadowRoot.zig"); { const et = target.asEventTarget(); event._target = et; event._dispatch_target = et; // Store original target for composedPath() } const page = self.page; // Set window.event to the currently dispatching event (WHATWG spec) const window = page.window; const prev_event = window._current_event; window._current_event = event; defer window._current_event = prev_event; var was_handled = false; // Create a single scope for all event handlers in this dispatch. // This ensures function handles passed to queueMicrotask remain valid // throughout the entire dispatch, preventing crashes when microtasks run. var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer { if (was_handled) { ls.local.runMicrotasks(); } ls.deinit(); } const activation_state = ActivationState.create(event, target, page); // Defer runs even on early return - ensures event phase is reset // and default actions execute (unless prevented) defer { event._event_phase = .none; event._stop_propagation = false; event._stop_immediate_propagation = false; // Handle checkbox/radio activation rollback or commit if (activation_state) |state| { state.restore(event, page); } // Execute default action if not prevented if (event._prevent_default) { // can't return in a defer (╯°□°)╯︵ ┻━┻ } else if (event._type_string.eql(comptime .wrap("click"))) { page.handleClick(target) catch |err| { log.warn(.event, "page.click", .{ .err = err }); }; } else if (event._type_string.eql(comptime .wrap("keydown"))) { page.handleKeydown(target, event) catch |err| { log.warn(.event, "page.keydown", .{ .err = err }); }; } } var path_len: usize = 0; var path_buffer: [128]*EventTarget = undefined; var node: ?*Node = target; while (node) |n| { if (path_len >= path_buffer.len) break; path_buffer[path_len] = n.asEventTarget(); path_len += 1; // Check if this node is a shadow root if (n.is(ShadowRoot)) |shadow| { event._needs_retargeting = true; // If event is not composed, stop at shadow boundary if (!event._composed) { break; } // Otherwise, jump to the shadow host and continue node = shadow._host.asNode(); continue; } node = n._parent; } // Even though the window isn't part of the DOM, most events propagate // through it in the capture phase (unless we stopped at a shadow boundary) // The only explicit exception is "load" if (event._type_string.eql(comptime .wrap("load")) == false) { if (path_len < path_buffer.len) { path_buffer[path_len] = page.window.asEventTarget(); path_len += 1; } } const path = path_buffer[0..path_len]; // Phase 1: Capturing phase (root → target, excluding target) // This happens for all events, regardless of bubbling event._event_phase = .capturing_phase; var i: usize = path_len; while (i > 1) { i -= 1; if (event._stop_propagation) return; const current_target = path[i]; if (self.lookup.get(.{ .event_target = @intFromPtr(current_target), .type_string = event._type_string, })) |list| { try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(true, opts)); } } // Phase 2: At target if (event._stop_propagation) return; event._event_phase = .at_target; const target_et = target.asEventTarget(); blk: { // Get inline handler (e.g., onclick property) for this target if (self.getInlineHandler(target_et, event)) |inline_handler| { was_handled = true; event._current_target = target_et; try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event}); if (event._stop_propagation) { return; } if (event._stop_immediate_propagation) { break :blk; } } if (self.lookup.get(.{ .type_string = event._type_string, .event_target = @intFromPtr(target_et), })) |list| { try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts)); if (event._stop_propagation) { return; } } } // Phase 3: Bubbling phase (target → root, excluding target) // This only happens if the event bubbles if (event._bubbles) { event._event_phase = .bubbling_phase; for (path[1..]) |current_target| { if (event._stop_propagation) break; if (self.lookup.get(.{ .type_string = event._type_string, .event_target = @intFromPtr(current_target), })) |list| { try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(false, opts)); } } } } const DispatchPhaseOpts = struct { capture_only: ?bool = null, apply_ignore: bool = false, fn init(capture_only: ?bool, opts: DispatchOpts) DispatchPhaseOpts { return .{ .capture_only = capture_only, .apply_ignore = opts.apply_ignore, }; } }; fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, local: *const js.Local, comptime opts: DispatchPhaseOpts) !void { const page = self.page; // Track dispatch depth for deferred removal self.dispatch_depth += 1; defer { const dispatch_depth = self.dispatch_depth; // Only destroy deferred listeners when we exit the outermost dispatch if (dispatch_depth == 1) { for (self.deferred_removals.items) |removal| { removal.list.remove(&removal.listener.node); self.listener_pool.destroy(removal.listener); } self.deferred_removals.clearRetainingCapacity(); } else { self.dispatch_depth = dispatch_depth - 1; } } // Use the last listener in the list as sentinel - listeners added during dispatch will be after it const last_node = list.last orelse return; const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node)); // Iterate through the list, stopping after we've encountered the last_listener var node = list.first; var is_done = false; node_loop: while (node) |n| { if (is_done) { break; } const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); is_done = (listener == last_listener); node = n.next; // Skip non-matching listeners if (comptime opts.capture_only) |capture| { if (listener.capture != capture) { continue; } } // Skip removed listeners if (listener.removed) { continue; } // If the listener has an aborted signal, remove it and skip if (listener.signal) |signal| { if (signal.getAborted()) { self.removeListener(list, listener); continue; } } if (comptime opts.apply_ignore) { for (self.ignore_list.items) |ignored| { if (ignored == listener) { continue :node_loop; } } } // Remove "once" listeners BEFORE calling them so nested dispatches don't see them if (listener.once) { self.removeListener(list, listener); } was_handled.* = true; event._current_target = current_target; // Compute adjusted target for shadow DOM retargeting (only if needed) const original_target = event._target; if (event._needs_retargeting) { event._target = getAdjustedTarget(original_target, current_target); } switch (listener.function) { .value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}), .string => |string| { const str = try page.call_arena.dupeZ(u8, string.str()); try local.eval(str, null); }, .object => |obj_global| { const obj = local.toLocal(obj_global); if (try obj.getFunction("handleEvent")) |handleEvent| { try handleEvent.callWithThis(void, obj, .{event}); } }, } // Restore original target (only if we changed it) if (event._needs_retargeting) { event._target = original_target; } if (event._stop_immediate_propagation) { return; } } } fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global { const global_event_handlers = @import("webapi/global_event_handlers.zig"); const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null; // Look up the inline handler for this target const html_element = switch (target._type) { .node => |n| n.is(Element.Html) orelse return null, else => return null, }; return html_element.getAttributeFunction(handler_type, self.page) catch |err| { log.warn(.event, "inline html callback", .{ .type = handler_type, .err = err }); return null; }; } fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { // If we're in a dispatch, defer removal to avoid invalidating iteration if (self.dispatch_depth > 0) { listener.removed = true; self.deferred_removals.append(self.arena, .{ .list = list, .listener = listener }) catch unreachable; } else { // Outside dispatch, remove immediately list.remove(&listener.node); self.listener_pool.destroy(listener); } } fn findListener(list: *const std.DoublyLinkedList, callback: Callback, capture: bool) ?*Listener { var node = list.first; while (node) |n| { node = n.next; const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); const matches = switch (callback) { .object => |obj| listener.function.eqlObject(obj), .function => |func| listener.function.eqlFunction(func), }; if (!matches) { continue; } if (listener.capture != capture) { continue; } return listener; } return null; } const Listener = struct { typ: String, once: bool, capture: bool, passive: bool, function: Function, signal: ?*@import("webapi/AbortSignal.zig") = null, node: std.DoublyLinkedList.Node, removed: bool = false, }; const Function = union(enum) { value: js.Function.Global, string: String, object: js.Object.Global, fn eqlFunction(self: Function, func: js.Function) bool { return switch (self) { .value => |v| v.isEqual(func), else => false, }; } fn eqlObject(self: Function, obj: js.Object) bool { return switch (self) { .object => |o| return o.isEqual(obj), else => false, }; } }; // Computes the adjusted target for shadow DOM event retargeting // Returns the lowest shadow-including ancestor of original_target that is // also an ancestor-or-self of current_target fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget { const ShadowRoot = @import("webapi/ShadowRoot.zig"); const orig_node = switch ((original_target orelse return null)._type) { .node => |n| n, else => return original_target, }; const curr_node = switch (current_target._type) { .node => |n| n, else => return original_target, }; // Walk up from original target, checking if we can reach current target var node: ?*Node = orig_node; while (node) |n| { // Check if current_target is an ancestor of n (or n itself) if (isAncestorOrSelf(curr_node, n)) { return n.asEventTarget(); } // Cross shadow boundary if needed if (n.is(ShadowRoot)) |shadow| { node = shadow._host.asNode(); continue; } node = n._parent; } return original_target; } // Check if ancestor is an ancestor of (or the same as) node // WITHOUT crossing shadow boundaries (just regular DOM tree) fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool { if (ancestor == node) { return true; } var current: ?*Node = node._parent; while (current) |n| { if (n == ancestor) { return true; } current = n._parent; } return false; } // Handles the default action for clicking on input checked/radio. Maybe this // could be generalized if needed, but I'm not sure. This wasn't obvious to me // but when an input is clicked, it's important to think about both the intent // and the actual result. Imagine you have an unchecked checkbox. When clicked, // the checkbox immediately becomes checked, and event handlers see this "checked" // intent. But a listener can preventDefault() in which case the check we did at // the start will be undone. // This is a bit more complicated for radio buttons, as the checking/unchecking // and the rollback can impact a different radio input. So if you "check" a radio // the intent is that it becomes checked and whatever was checked before becomes // unchecked, so that if you have to rollback (because of a preventDefault()) // then both inputs have to revert to their original values. const ActivationState = struct { old_checked: bool, input: *Element.Html.Input, previously_checked_radio: ?*Input, const Input = Element.Html.Input; fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState { if (event._type_string.eql(comptime .wrap("click")) == false) { return null; } const input = target.is(Element.Html.Input) orelse return null; if (input._input_type != .checkbox and input._input_type != .radio) { return null; } const old_checked = input._checked; var previously_checked_radio: ?*Element.Html.Input = null; // For radio buttons, find the currently checked radio in the group if (input._input_type == .radio and !old_checked) { previously_checked_radio = try findCheckedRadioInGroup(input, page); } // Toggle checkbox or check radio (which unchecks others in group) const new_checked = if (input._input_type == .checkbox) !old_checked else true; try input.setChecked(new_checked, page); return .{ .input = input, .old_checked = old_checked, .previously_checked_radio = previously_checked_radio, }; } fn restore(self: *const ActivationState, event: *const Event, page: *Page) void { const input = self.input; if (event._prevent_default) { // Rollback: restore previous state input._checked = self.old_checked; input._checked_dirty = true; if (self.previously_checked_radio) |prev_radio| { prev_radio._checked = true; prev_radio._checked_dirty = true; } return; } // Commit: fire input and change events only if state actually changed // and the element is connected to a document (detached elements don't fire). // For checkboxes, state always changes. For radios, only if was unchecked. const state_changed = (input._input_type == .checkbox) or !self.old_checked; if (state_changed and input.asElement().asNode().isConnected()) { fireEvent(page, input, "input") catch |err| { log.warn(.event, "input event", .{ .err = err }); }; fireEvent(page, input, "change") catch |err| { log.warn(.event, "change event", .{ .err = err }); }; } } fn findCheckedRadioInGroup(input: *Input, page: *Page) !?*Input { const elem = input.asElement(); const name = elem.getAttributeSafe(comptime .wrap("name")) orelse return null; if (name.len == 0) { return null; } const form = input.getForm(page); // Walk from the root of the tree containing this element // This handles both document-attached and orphaned elements const root = elem.asNode().getRootNode(null); const TreeWalker = @import("webapi/TreeWalker.zig"); var walker = TreeWalker.Full.init(root, .{}); while (walker.next()) |node| { const other_element = node.is(Element) orelse continue; const other_input = other_element.is(Input) orelse continue; if (other_input._input_type != .radio) { continue; } // Skip the input we're checking from if (other_input == input) { continue; } const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue; if (!std.mem.eql(u8, name, other_name)) { continue; } // Check if same form context const other_form = other_input.getForm(page); if (form) |f| { const of = other_form orelse continue; if (f != of) { continue; // Different forms } } else if (other_form != null) { continue; // form is null but other has a form } if (other_input._checked) { return other_input; } } return null; } // Fire input or change event fn fireEvent(page: *Page, input: *Input, comptime typ: []const u8) !void { const event = try Event.initTrusted(comptime .wrap(typ), .{ .bubbles = true, .cancelable = false, }, page); const target = input.asElement().asEventTarget(); try page._event_manager.dispatch(target, event); } }; ================================================ FILE: src/browser/Factory.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const builtin = @import("builtin"); const reflect = @import("reflect.zig"); const log = @import("../log.zig"); const String = @import("../string.zig").String; const SlabAllocator = @import("../slab.zig").SlabAllocator; const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const UIEvent = @import("webapi/event/UIEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig"); const Element = @import("webapi/Element.zig"); const Document = @import("webapi/Document.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig"); const Blob = @import("webapi/Blob.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); const Allocator = std.mem.Allocator; const IS_DEBUG = builtin.mode == .Debug; const assert = std.debug.assert; // Shared across all frames of a Page. const Factory = @This(); _arena: Allocator, _slab: SlabAllocator, pub fn init(arena: Allocator) Factory { return .{ ._arena = arena, ._slab = SlabAllocator.init(arena, 128), }; } // this is a root object pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { return self.eventTargetWithAllocator(self._slab.allocator(), child); } pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ EventTarget, @TypeOf(child) }, ).allocate(allocator); const event_ptr = chain.get(0); event_ptr.* = .{ ._type = unionInit(EventTarget.Type, chain.get(1)), }; chain.setLeaf(1, child); return chain.get(1); } pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget { const allocator = self._slab.allocator(); const et = try allocator.create(EventTarget); et.* = .{ ._type = unionInit(EventTarget.Type, child) }; return et; } // this is a root object pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ Event, @TypeOf(child) }, ).allocate(arena); // Special case: Event has a _type_string field, so we need manual setup const event_ptr = chain.get(0); event_ptr.* = try eventInit(arena, typ, chain.get(1)); chain.setLeaf(1, child); return chain.get(1); } pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ Event, UIEvent, @TypeOf(child) }, ).allocate(arena); // Special case: Event has a _type_string field, so we need manual setup const event_ptr = chain.get(0); event_ptr.* = try eventInit(arena, typ, chain.get(1)); chain.setMiddle(1, UIEvent.Type); chain.setLeaf(2, child); return chain.get(2); } pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ Event, UIEvent, MouseEvent, @TypeOf(child) }, ).allocate(arena); // Special case: Event has a _type_string field, so we need manual setup const event_ptr = chain.get(0); event_ptr.* = try eventInit(arena, typ, chain.get(1)); chain.setMiddle(1, UIEvent.Type); // Set MouseEvent with all its fields const mouse_ptr = chain.get(2); mouse_ptr.* = mouse; mouse_ptr._proto = chain.get(1); mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3)); chain.setLeaf(3, child); return chain.get(3); } fn PrototypeChain(comptime types: []const type) type { return struct { const Self = @This(); memory: []u8, fn totalSize() usize { var size: usize = 0; for (types) |T| { size = std.mem.alignForward(usize, size, @alignOf(T)); size += @sizeOf(T); } return size; } fn maxAlign() std.mem.Alignment { var alignment: std.mem.Alignment = .@"1"; for (types) |T| { alignment = std.mem.Alignment.max(alignment, std.mem.Alignment.of(T)); } return alignment; } fn getType(comptime index: usize) type { return types[index]; } fn allocate(allocator: std.mem.Allocator) !Self { const size = comptime Self.totalSize(); const alignment = comptime Self.maxAlign(); const memory = try allocator.alignedAlloc(u8, alignment, size); return .{ .memory = memory }; } fn get(self: *const Self, comptime index: usize) *getType(index) { var offset: usize = 0; inline for (types, 0..) |T, i| { offset = std.mem.alignForward(usize, offset, @alignOf(T)); if (i == index) { return @as(*T, @ptrCast(@alignCast(self.memory.ptr + offset))); } offset += @sizeOf(T); } unreachable; } fn set(self: *const Self, comptime index: usize, value: getType(index)) void { const ptr = self.get(index); ptr.* = value; } fn setRoot(self: *const Self, comptime T: type) void { const ptr = self.get(0); ptr.* = .{ ._type = unionInit(T, self.get(1)) }; } fn setMiddle(self: *const Self, comptime index: usize, comptime T: type) void { assert(index >= 1); assert(index < types.len); const ptr = self.get(index); ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, self.get(index + 1)) }; } fn setMiddleWithValue(self: *const Self, comptime index: usize, comptime T: type, value: anytype) void { assert(index >= 1); const ptr = self.get(index); ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, value) }; } fn setLeaf(self: *const Self, comptime index: usize, value: anytype) void { assert(index >= 1); const ptr = self.get(index); ptr.* = value; ptr._proto = self.get(index - 1); } }; } fn AutoPrototypeChain(comptime types: []const type) type { return struct { fn create(allocator: std.mem.Allocator, leaf_value: anytype) !*@TypeOf(leaf_value) { const chain = try PrototypeChain(types).allocate(allocator); const RootType = types[0]; chain.setRoot(RootType.Type); inline for (1..types.len - 1) |i| { const MiddleType = types[i]; chain.setMiddle(i, MiddleType.Type); } chain.setLeaf(types.len - 1, leaf_value); return chain.get(types.len - 1); } }; } fn eventInit(arena: Allocator, typ: String, value: anytype) !Event { // Round to 2ms for privacy (browsers do this) const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic); const time_stamp = (raw_timestamp / 2) * 2; return .{ ._rc = 0, ._arena = arena, ._type = unionInit(Event.Type, value), ._type_string = typ, ._time_stamp = time_stamp, }; } pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) { // Special case: Blob has slice and mime fields, so we need manual setup const chain = try PrototypeChain( &.{ Blob, @TypeOf(child) }, ).allocate(arena); const blob_ptr = chain.get(0); blob_ptr.* = .{ ._arena = arena, ._type = unionInit(Blob.Type, chain.get(1)), ._slice = "", ._mime = "", }; chain.setLeaf(1, child); return chain.get(1); } pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, page: *Page) !*@TypeOf(child) { const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(arena); const doc = page.document.asNode(); const abstract_range = chain.get(0); abstract_range.* = AbstractRange{ ._rc = 0, ._arena = arena, ._page_id = page.id, ._type = unionInit(AbstractRange.Type, chain.get(1)), ._end_offset = 0, ._start_offset = 0, ._end_container = doc, ._start_container = doc, }; chain.setLeaf(1, child); page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, @TypeOf(child) }, ).create(allocator, child); } pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Document, @TypeOf(child) }, ).create(allocator, child); } pub fn documentFragment(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Node.DocumentFragment, @TypeOf(child) }, ).create(allocator, child); } pub fn element(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Element, @TypeOf(child) }, ).create(allocator, child); } pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Element, Element.Html, @TypeOf(child) }, ).create(allocator, child); } pub fn htmlMediaElement(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( &.{ EventTarget, Node, Element, Element.Html, Element.Html.Media, @TypeOf(child) }, ).create(allocator, child); } pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); const ChildT = @TypeOf(child); if (ChildT == Element.Svg) { return self.element(child); } const chain = try PrototypeChain( &.{ EventTarget, Node, Element, Element.Svg, ChildT }, ).allocate(allocator); chain.setRoot(EventTarget.Type); chain.setMiddle(1, Node.Type); chain.setMiddle(2, Element.Type); // will never allocate, can't fail const tag_name_str = String.init(self._arena, tag_name, .{}) catch unreachable; // Manually set Element.Svg with the tag_name chain.set(3, .{ ._proto = chain.get(2), ._tag_name = tag_name_str, ._type = unionInit(Element.Svg.Type, chain.get(4)), }); chain.setLeaf(4, child); return chain.get(4); } pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) { return try AutoPrototypeChain( &.{ EventTarget, XMLHttpRequestEventTarget, @TypeOf(child) }, ).create(allocator, child); } pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); const TextTrackCue = @import("webapi/media/TextTrackCue.zig"); return try AutoPrototypeChain( &.{ EventTarget, TextTrackCue, @TypeOf(child) }, ).create(allocator, child); } pub fn destroy(self: *Factory, value: anytype) void { const S = reflect.Struct(@TypeOf(value)); if (comptime IS_DEBUG) { // We should always destroy from the leaf down. if (@hasDecl(S, "_prototype_root")) { // A Event{._type == .generic} (or any other similar types) // _should_ be destoyed directly. The _type = .generic is a pseudo // child if (S != Event or value._type != .generic) { log.fatal(.bug, "factory.destroy.event", .{ .type = @typeName(S) }); unreachable; } } } if (comptime @hasField(S, "_proto")) { self.destroyChain(value, 0, std.mem.Alignment.@"1"); } else { self.destroyStandalone(value); } } pub fn destroyStandalone(self: *Factory, value: anytype) void { const allocator = self._slab.allocator(); allocator.destroy(value); } fn destroyChain( self: *Factory, value: anytype, old_size: usize, old_align: std.mem.Alignment, ) void { const S = reflect.Struct(@TypeOf(value)); const allocator = self._slab.allocator(); // aligns the old size to the alignment of this element const current_size = std.mem.alignForward(usize, old_size, @alignOf(S)); const new_size = current_size + @sizeOf(S); const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S)); if (@hasField(S, "_proto")) { self.destroyChain(value._proto, new_size, new_align); } else { // no proto so this is the head of the chain. // we use this as the ptr to the start of the chain. // and we have summed up the length. assert(@hasDecl(S, "_prototype_root")); const memory_ptr: [*]u8 = @ptrCast(@constCast(value)); const len = std.mem.alignForward(usize, new_size, new_align.toByteUnits()); allocator.rawFree(memory_ptr[0..len], new_align, @returnAddress()); } } pub fn createT(self: *Factory, comptime T: type) !*T { const allocator = self._slab.allocator(); return try allocator.create(T); } pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) { const ptr = try self.createT(@TypeOf(value)); ptr.* = value; return ptr; } fn unionInit(comptime T: type, value: anytype) T { const V = @TypeOf(value); const field_name = comptime unionFieldName(T, V); return @unionInit(T, field_name, value); } // There can be friction between comptime and runtime. Comptime has to // account for all possible types, even if some runtime flow makes certain // cases impossible. At runtime, we always call `unionFieldName` with the // correct struct or pointer type. But at comptime time, `unionFieldName` // is called with both variants (S and *S). So we use reflect.Struct(). // This only works because we never have a union with a field S and another // field *S. fn unionFieldName(comptime T: type, comptime V: type) []const u8 { inline for (@typeInfo(T).@"union".fields) |field| { if (reflect.Struct(field.type) == reflect.Struct(V)) { return field.name; } } @compileError(@typeName(V) ++ " is not a valid type for " ++ @typeName(T) ++ ".type"); } ================================================ FILE: src/browser/HttpClient.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const lp = @import("lightpanda"); const log = @import("../log.zig"); const Net = @import("../network/http.zig"); const Network = @import("../network/Runtime.zig"); const Config = @import("../Config.zig"); const URL = @import("../browser/URL.zig"); const Notification = @import("../Notification.zig"); const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; const Robots = @import("../network/Robots.zig"); const RobotStore = Robots.RobotStore; const WebBotAuth = @import("../network/WebBotAuth.zig"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const IS_DEBUG = builtin.mode == .Debug; pub const Method = Net.Method; pub const Headers = Net.Headers; pub const ResponseHead = Net.ResponseHead; pub const HeaderIterator = Net.HeaderIterator; // This is loosely tied to a browser Page. Loading all the , doing // XHR requests, and loading imports all happens through here. Sine the app // currently supports 1 browser and 1 page at-a-time, we only have 1 Client and // re-use it from page to page. This allows us better re-use of the various // buffers/caches (including keepalive connections) that libcurl has. // // The app has other secondary http needs, like telemetry. While we want to // share some things (namely the ca blob, and maybe some configuration // (TODO: ??? should proxy settings be global ???)), we're able to do call // client.abort() to abort the transfers being made by a page, without impacting // those other http requests. pub const Client = @This(); // Count of active requests active: usize, // Count of intercepted requests. This is to help deal with intercepted requests. // The client doesn't track intercepted transfers. If a request is intercepted, // the client forgets about it and requires the interceptor to continue or abort // it. That works well, except if we only rely on active, we might think there's // no more network activity when, with interecepted requests, there might be more // in the future. (We really only need this to properly emit a 'networkIdle' and // 'networkAlmostIdle' Page.lifecycleEvent in CDP). intercepted: usize, // Our curl multi handle. handles: Net.Handles, // Connections currently in this client's curl_multi. in_use: std.DoublyLinkedList = .{}, // Connections that failed to be removed from curl_multi during perform. dirty: std.DoublyLinkedList = .{}, // Whether we're currently inside a curl_multi_perform call. performing: bool = false, // Use to generate the next request ID next_request_id: u32 = 0, // When handles has no more available easys, requests get queued. queue: TransferQueue, // The main app allocator allocator: Allocator, network: *Network, // Queue of requests that depend on a robots.txt. // Allows us to fetch the robots.txt just once. pending_robots_queue: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empty, // Once we have a handle/easy to process a request with, we create a Transfer // which contains the Request as well as any state we need to process the // request. These wil come and go with each request. transfer_pool: std.heap.MemoryPool(Transfer), // The current proxy. CDP can change it, restoreOriginalProxy restores // from config. http_proxy: ?[:0]const u8 = null, // track if the client use a proxy for connections. // We can't use http_proxy because we want also to track proxy configured via // CDP. use_proxy: bool, // Current TLS verification state, applied per-connection in makeRequest. tls_verify: bool = true, obey_robots: bool, cdp_client: ?CDPClient = null, // libcurl can monitor arbitrary sockets, this lets us use libcurl to poll // both HTTP data as well as messages from an CDP connection. // Furthermore, we have some tension between blocking scripts and request // interception. For non-blocking scripts, because nothing blocks, we can // just queue the scripts until we receive a response to the interception // notification. But for blocking scripts (which block the parser), it's hard // to return control back to the CDP loop. So the `read` function pointer is // used by the Client to have the CDP client read more data from the socket, // specifically when we're waiting for a request interception response to // a blocking script. pub const CDPClient = struct { socket: posix.socket_t, ctx: *anyopaque, blocking_read_start: *const fn (*anyopaque) bool, blocking_read: *const fn (*anyopaque) bool, blocking_read_end: *const fn (*anyopaque) bool, }; const TransferQueue = std.DoublyLinkedList; pub fn init(allocator: Allocator, network: *Network) !*Client { var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator); errdefer transfer_pool.deinit(); const client = try allocator.create(Client); errdefer allocator.destroy(client); var handles = try Net.Handles.init(network.config); errdefer handles.deinit(); const http_proxy = network.config.httpProxy(); client.* = .{ .queue = .{}, .active = 0, .intercepted = 0, .handles = handles, .allocator = allocator, .network = network, .http_proxy = http_proxy, .use_proxy = http_proxy != null, .tls_verify = network.config.tlsVerifyHost(), .obey_robots = network.config.obeyRobots(), .transfer_pool = transfer_pool, }; return client; } pub fn deinit(self: *Client) void { self.abort(); self.handles.deinit(); self.transfer_pool.deinit(); var robots_iter = self.pending_robots_queue.iterator(); while (robots_iter.next()) |entry| { entry.value_ptr.deinit(self.allocator); } self.pending_robots_queue.deinit(self.allocator); self.allocator.destroy(self); } pub fn newHeaders(self: *const Client) !Net.Headers { return Net.Headers.init(self.network.config.http_headers.user_agent_header); } pub fn abort(self: *Client) void { self._abort(true, 0); } pub fn abortFrame(self: *Client, frame_id: u32) void { self._abort(false, frame_id); } // Written this way so that both abort and abortFrame can share the same code // but abort can avoid the frame_id check at comptime. fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void { { var q = &self.in_use; var n = q.first; while (n) |node| { n = node.next; const conn: *Net.Connection = @fieldParentPtr("node", node); var transfer = Transfer.fromConnection(conn) catch |err| { // Let's cleanup what we can self.removeConn(conn); log.err(.http, "get private info", .{ .err = err, .source = "abort" }); continue; }; if (comptime abort_all) { transfer.kill(); } else if (transfer.req.frame_id == frame_id) { q.remove(node); transfer.kill(); } } } if (comptime IS_DEBUG and abort_all) { std.debug.assert(self.active == 0); } { var q = &self.queue; var n = q.first; while (n) |node| { n = node.next; const transfer: *Transfer = @fieldParentPtr("_node", node); if (comptime abort_all) { transfer.kill(); } else if (transfer.req.frame_id == frame_id) { q.remove(node); transfer.kill(); } } } if (comptime abort_all) { self.queue = .{}; } if (comptime IS_DEBUG and abort_all) { std.debug.assert(self.in_use.first == null); const running = self.handles.perform() catch |err| { lp.assert(false, "multi perform in abort", .{ .err = err }); }; std.debug.assert(running == 0); } } pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus { while (self.queue.popFirst()) |queue_node| { const conn = self.network.getConnection() orelse { self.queue.prepend(queue_node); break; }; const transfer: *Transfer = @fieldParentPtr("_node", queue_node); try self.makeRequest(conn, transfer); } return self.perform(@intCast(timeout_ms)); } pub fn request(self: *Client, req: Request) !void { if (self.obey_robots == false) { return self.processRequest(req); } const robots_url = try URL.getRobotsUrl(self.allocator, req.url); errdefer self.allocator.free(robots_url); // If we have this robots cached, we can take a fast path. if (self.network.robot_store.get(robots_url)) |robot_entry| { defer self.allocator.free(robots_url); switch (robot_entry) { // If we have a found robots entry, we check it. .present => |robots| { const path = URL.getPathname(req.url); if (!robots.isAllowed(path)) { req.error_callback(req.ctx, error.RobotsBlocked); return; } }, // Otherwise, we assume we won't find it again. .absent => {}, } return self.processRequest(req); } return self.fetchRobotsThenProcessRequest(robots_url, req); } fn processRequest(self: *Client, req: Request) !void { const transfer = try self.makeTransfer(req); transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer }); var wait_for_interception = false; transfer.req.notification.dispatch(.http_request_intercept, &.{ .transfer = transfer, .wait_for_interception = &wait_for_interception, }); if (wait_for_interception == false) { // request not intercepted, process it normally return self.process(transfer); } self.intercepted += 1; if (comptime IS_DEBUG) { log.debug(.http, "wait for interception", .{ .intercepted = self.intercepted }); } transfer._intercept_state = .pending; if (req.blocking == false) { // The request was interecepted, but it isn't a blocking request, so we // dont' need to block this call. The request will be unblocked // asynchronously via either continueTransfer or abortTransfer return; } if (try self.waitForInterceptedResponse(transfer)) { return self.process(transfer); } } const RobotsRequestContext = struct { client: *Client, req: Request, robots_url: [:0]const u8, buffer: std.ArrayList(u8), status: u16 = 0, pub fn deinit(self: *RobotsRequestContext) void { self.client.allocator.free(self.robots_url); self.buffer.deinit(self.client.allocator); self.client.allocator.destroy(self); } }; fn fetchRobotsThenProcessRequest(self: *Client, robots_url: [:0]const u8, req: Request) !void { const entry = try self.pending_robots_queue.getOrPut(self.allocator, robots_url); if (!entry.found_existing) { errdefer self.allocator.free(robots_url); // If we aren't already fetching this robots, // we want to create a new queue for it and add this request into it. entry.value_ptr.* = .empty; const ctx = try self.allocator.create(RobotsRequestContext); errdefer self.allocator.destroy(ctx); ctx.* = .{ .client = self, .req = req, .robots_url = robots_url, .buffer = .empty }; const headers = try self.newHeaders(); log.debug(.browser, "fetching robots.txt", .{ .robots_url = robots_url }); try self.processRequest(.{ .ctx = ctx, .url = robots_url, .method = .GET, .headers = headers, .blocking = false, .frame_id = req.frame_id, .cookie_jar = req.cookie_jar, .notification = req.notification, .resource_type = .fetch, .header_callback = robotsHeaderCallback, .data_callback = robotsDataCallback, .done_callback = robotsDoneCallback, .error_callback = robotsErrorCallback, .shutdown_callback = robotsShutdownCallback, }); } else { // Not using our own robots URL, only using the one from the first request. self.allocator.free(robots_url); } try entry.value_ptr.append(self.allocator, req); } fn robotsHeaderCallback(transfer: *Transfer) !bool { const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx)); if (transfer.response_header) |hdr| { log.debug(.browser, "robots status", .{ .status = hdr.status, .robots_url = ctx.robots_url }); ctx.status = hdr.status; } if (transfer.getContentLength()) |cl| { try ctx.buffer.ensureTotalCapacity(ctx.client.allocator, cl); } return true; } fn robotsDataCallback(transfer: *Transfer, data: []const u8) !void { const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx)); try ctx.buffer.appendSlice(ctx.client.allocator, data); } fn robotsDoneCallback(ctx_ptr: *anyopaque) !void { const ctx: *RobotsRequestContext = @ptrCast(@alignCast(ctx_ptr)); defer ctx.deinit(); var allowed = true; switch (ctx.status) { 200 => { if (ctx.buffer.items.len > 0) { const robots: ?Robots = ctx.client.network.robot_store.robotsFromBytes( ctx.client.network.config.http_headers.user_agent, ctx.buffer.items, ) catch blk: { log.warn(.browser, "failed to parse robots", .{ .robots_url = ctx.robots_url }); // If we fail to parse, we just insert it as absent and ignore. try ctx.client.network.robot_store.putAbsent(ctx.robots_url); break :blk null; }; if (robots) |r| { try ctx.client.network.robot_store.put(ctx.robots_url, r); const path = URL.getPathname(ctx.req.url); allowed = r.isAllowed(path); } } }, 404 => { log.debug(.http, "robots not found", .{ .url = ctx.robots_url }); // If we get a 404, we just insert it as absent. try ctx.client.network.robot_store.putAbsent(ctx.robots_url); }, else => { log.debug(.http, "unexpected status on robots", .{ .url = ctx.robots_url, .status = ctx.status }); // If we get an unexpected status, we just insert as absent. try ctx.client.network.robot_store.putAbsent(ctx.robots_url); }, } var queued = ctx.client.pending_robots_queue.fetchRemove( ctx.robots_url, ) orelse @panic("Client.robotsDoneCallbacke empty queue"); defer queued.value.deinit(ctx.client.allocator); for (queued.value.items) |queued_req| { if (!allowed) { log.warn(.http, "blocked by robots", .{ .url = queued_req.url }); queued_req.error_callback(queued_req.ctx, error.RobotsBlocked); } else { ctx.client.processRequest(queued_req) catch |e| { queued_req.error_callback(queued_req.ctx, e); }; } } } fn robotsErrorCallback(ctx_ptr: *anyopaque, err: anyerror) void { const ctx: *RobotsRequestContext = @ptrCast(@alignCast(ctx_ptr)); defer ctx.deinit(); log.warn(.http, "robots fetch failed", .{ .err = err }); var queued = ctx.client.pending_robots_queue.fetchRemove( ctx.robots_url, ) orelse @panic("Client.robotsErrorCallback empty queue"); defer queued.value.deinit(ctx.client.allocator); // On error, allow all queued requests to proceed for (queued.value.items) |queued_req| { ctx.client.processRequest(queued_req) catch |e| { queued_req.error_callback(queued_req.ctx, e); }; } } fn robotsShutdownCallback(ctx_ptr: *anyopaque) void { const ctx: *RobotsRequestContext = @ptrCast(@alignCast(ctx_ptr)); defer ctx.deinit(); log.debug(.http, "robots fetch shutdown", .{}); var queued = ctx.client.pending_robots_queue.fetchRemove( ctx.robots_url, ) orelse @panic("Client.robotsErrorCallback empty queue"); defer queued.value.deinit(ctx.client.allocator); for (queued.value.items) |queued_req| { if (queued_req.shutdown_callback) |shutdown_cb| { shutdown_cb(queued_req.ctx); } } } fn waitForInterceptedResponse(self: *Client, transfer: *Transfer) !bool { // The request was intercepted and is blocking. This is messy, but our // callers, the ScriptManager -> Page, don't have a great way to stop the // parser and return control to the CDP server to wait for the interception // response. We have some information on the CDPClient, so we'll do the // blocking here. (This is a bit of a legacy thing. Initially the Client // had a 'extra_socket' that it could monitor. It was named 'extra_socket' // to appear generic, but really, that 'extra_socket' was always the CDP // socket. Because we already had the "extra_socket" here, it was easier to // make it even more CDP- aware and turn `extra_socket: socket_t` into the // current CDPClient and do the blocking here). const cdp_client = self.cdp_client.?; const ctx = cdp_client.ctx; if (cdp_client.blocking_read_start(ctx) == false) { return error.BlockingInterceptFailure; } defer _ = cdp_client.blocking_read_end(ctx); while (true) { if (cdp_client.blocking_read(ctx) == false) { return error.BlockingInterceptFailure; } switch (transfer._intercept_state) { .pending => continue, // keep waiting .@"continue" => return true, .abort => |err| { transfer.abort(err); return false; }, .fulfilled => { // callbacks already called, just need to cleanups transfer.deinit(); return false; }, .not_intercepted => unreachable, } } } // Above, request will not process if there's an interception request. In such // cases, the interecptor is expected to call resume to continue the transfer // or transfer.abort() to abort it. fn process(self: *Client, transfer: *Transfer) !void { // libcurl doesn't allow recursive calls, if we're in a `perform()` operation // then we _have_ to queue this. if (self.performing == false) { if (self.network.getConnection()) |conn| { return self.makeRequest(conn, transfer); } } self.queue.append(&transfer._node); } // For an intercepted request pub fn continueTransfer(self: *Client, transfer: *Transfer) !void { if (comptime IS_DEBUG) { std.debug.assert(transfer._intercept_state != .not_intercepted); log.debug(.http, "continue transfer", .{ .intercepted = self.intercepted }); } self.intercepted -= 1; if (!transfer.req.blocking) { return self.process(transfer); } transfer._intercept_state = .@"continue"; } // For an intercepted request pub fn abortTransfer(self: *Client, transfer: *Transfer) void { if (comptime IS_DEBUG) { std.debug.assert(transfer._intercept_state != .not_intercepted); log.debug(.http, "abort transfer", .{ .intercepted = self.intercepted }); } self.intercepted -= 1; if (!transfer.req.blocking) { transfer.abort(error.Abort); } transfer._intercept_state = .{ .abort = error.Abort }; } // For an intercepted request pub fn fulfillTransfer(self: *Client, transfer: *Transfer, status: u16, headers: []const Net.Header, body: ?[]const u8) !void { if (comptime IS_DEBUG) { std.debug.assert(transfer._intercept_state != .not_intercepted); log.debug(.http, "filfull transfer", .{ .intercepted = self.intercepted }); } self.intercepted -= 1; try transfer.fulfill(status, headers, body); if (!transfer.req.blocking) { transfer.deinit(); return; } transfer._intercept_state = .fulfilled; } pub fn nextReqId(self: *Client) u32 { return self.next_request_id +% 1; } pub fn incrReqId(self: *Client) u32 { const id = self.next_request_id +% 1; self.next_request_id = id; return id; } fn makeTransfer(self: *Client, req: Request) !*Transfer { errdefer req.headers.deinit(); const transfer = try self.transfer_pool.create(); errdefer self.transfer_pool.destroy(transfer); const id = self.incrReqId(); transfer.* = .{ .arena = ArenaAllocator.init(self.allocator), .id = id, .url = req.url, .req = req, .ctx = req.ctx, .client = self, .max_response_size = self.network.config.httpMaxResponseSize(), }; return transfer; } fn requestFailed(transfer: *Transfer, err: anyerror, comptime execute_callback: bool) void { if (transfer._notified_fail) { // we can force a failed request within a callback, which will eventually // result in this being called again in the more general loop. We do this // because we can raise a more specific error inside a callback in some cases return; } transfer._notified_fail = true; transfer.req.notification.dispatch(.http_request_fail, &.{ .transfer = transfer, .err = err, }); if (execute_callback) { transfer.req.error_callback(transfer.ctx, err); } else if (transfer.req.shutdown_callback) |cb| { cb(transfer.ctx); } } // Restrictive since it'll only work if there are no inflight requests. In some // cases, the libcurl documentation is clear that changing settings while a // connection is inflight is undefined. It doesn't say anything about CURLOPT_PROXY, // but better to be safe than sorry. // For now, this restriction is ok, since it's only called by CDP on // createBrowserContext, at which point, if we do have an active connection, // that's probably a bug (a previous abort failed?). But if we need to call this // at any point in time, it could be worth digging into libcurl to see if this // can be changed at any point in the easy's lifecycle. pub fn changeProxy(self: *Client, proxy: [:0]const u8) !void { try self.ensureNoActiveConnection(); self.http_proxy = proxy; self.use_proxy = true; } // Same restriction as changeProxy. Should be ok since this is only called on // BrowserContext deinit. pub fn restoreOriginalProxy(self: *Client) !void { try self.ensureNoActiveConnection(); self.http_proxy = self.network.config.httpProxy(); self.use_proxy = self.http_proxy != null; } // Enable TLS verification on all connections. pub fn setTlsVerify(self: *Client, verify: bool) !void { // Remove inflight connections check on enable TLS b/c chromiumoxide calls // the command during navigate and Curl seems to accept it... var it = self.in_use.first; while (it) |node| : (it = node.next) { const conn: *Net.Connection = @fieldParentPtr("node", node); try conn.setTlsVerify(verify, self.use_proxy); } self.tls_verify = verify; } fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerror!void { const req = &transfer.req; { transfer._conn = conn; errdefer { transfer._conn = null; transfer.deinit(); self.releaseConn(conn); } // Set callbacks and per-client settings on the pooled connection. try conn.setCallbacks(Transfer.headerCallback, Transfer.dataCallback); try conn.setProxy(self.http_proxy); try conn.setTlsVerify(self.tls_verify, self.use_proxy); try conn.setURL(req.url); try conn.setMethod(req.method); if (req.body) |b| { try conn.setBody(b); } else { try conn.setGetMode(); } var header_list = req.headers; try conn.secretHeaders(&header_list, &self.network.config.http_headers); // Add headers that must be hidden from intercepts try conn.setHeaders(&header_list); // If we have WebBotAuth, sign our request. if (self.network.web_bot_auth) |*wba| { const authority = URL.getHost(req.url); try wba.signRequest(transfer.arena.allocator(), &header_list, authority); } // Add cookies. if (header_list.cookies) |cookies| { try conn.setCookies(cookies); } try conn.setPrivate(transfer); // add credentials if (req.credentials) |creds| { if (transfer._auth_challenge != null and transfer._auth_challenge.?.source == .proxy) { try conn.setProxyCredentials(creds); } else { try conn.setCredentials(creds); } } } // As soon as this is called, our "perform" loop is responsible for // cleaning things up. That's why the above code is in a block. If anything // fails BEFORE `curl_multi_add_handle` succeeds, the we still need to do // cleanup. But if things fail after `curl_multi_add_handle`, we expect // perfom to pickup the failure and cleanup. self.in_use.append(&conn.node); self.handles.add(conn) catch |err| { transfer._conn = null; transfer.deinit(); self.in_use.remove(&conn.node); self.releaseConn(conn); return err; }; if (req.start_callback) |cb| { cb(transfer) catch |err| { transfer.deinit(); return err; }; } self.active += 1; _ = try self.perform(0); } pub const PerformStatus = enum { cdp_socket, normal, }; fn perform(self: *Client, timeout_ms: c_int) !PerformStatus { const running = blk: { self.performing = true; defer self.performing = false; break :blk try self.handles.perform(); }; // Process dirty connections — return them to Runtime pool. while (self.dirty.popFirst()) |node| { const conn: *Net.Connection = @fieldParentPtr("node", node); self.handles.remove(conn) catch |err| { log.fatal(.http, "multi remove handle", .{ .err = err, .src = "perform" }); @panic("multi_remove_handle"); }; self.releaseConn(conn); } // We're potentially going to block for a while until we get data. Process // whatever messages we have waiting ahead of time. if (try self.processMessages()) { return .normal; } var status = PerformStatus.normal; if (self.cdp_client) |cdp_client| { var wait_fds = [_]Net.WaitFd{.{ .fd = cdp_client.socket, .events = .{ .pollin = true }, .revents = .{}, }}; try self.handles.poll(&wait_fds, timeout_ms); if (wait_fds[0].revents.pollin or wait_fds[0].revents.pollpri or wait_fds[0].revents.pollout) { status = .cdp_socket; } } else if (running > 0) { try self.handles.poll(&.{}, timeout_ms); } _ = try self.processMessages(); return status; } fn processMessages(self: *Client) !bool { var processed = false; while (self.handles.readMessage()) |msg| { const transfer = try Transfer.fromConnection(&msg.conn); // In case of auth challenge // TODO give a way to configure the number of auth retries. if (transfer._auth_challenge != null and transfer._tries < 10) { var wait_for_interception = false; transfer.req.notification.dispatch(.http_request_auth_required, &.{ .transfer = transfer, .wait_for_interception = &wait_for_interception }); if (wait_for_interception) { self.intercepted += 1; if (comptime IS_DEBUG) { log.debug(.http, "wait for auth interception", .{ .intercepted = self.intercepted }); } transfer._intercept_state = .pending; // Wether or not this is a blocking request, we're not going // to process it now. We can end the transfer, which will // release the easy handle back into the pool. The transfer // is still valid/alive (just has no handle). self.endTransfer(transfer); if (!transfer.req.blocking) { // In the case of an async request, we can just "forget" // about this transfer until it gets updated asynchronously // from some CDP command. continue; } // In the case of a sync request, we need to block until we // get the CDP command for handling this case. if (try self.waitForInterceptedResponse(transfer)) { // we've been asked to continue with the request // we can't process it here, since we're already inside // a process, so we need to queue it and wait for the // next tick (this is why it was safe to endTransfer // above, because even in the "blocking" path, we still // only process it on the next tick). self.queue.append(&transfer._node); } else { // aborted, already cleaned up } continue; } } // release it ASAP so that it's available; some done_callbacks // will load more resources. self.endTransfer(transfer); defer transfer.deinit(); if (msg.err) |err| { requestFailed(transfer, err, true); } else blk: { // make sure the transfer can't be immediately aborted from a callback // since we still need it here. transfer._performing = true; defer transfer._performing = false; if (!transfer._header_done_called) { // In case of request w/o data, we need to call the header done // callback now. const proceed = transfer.headerDoneCallback(&msg.conn) catch |err| { log.err(.http, "header_done_callback2", .{ .err = err }); requestFailed(transfer, err, true); continue; }; if (!proceed) { requestFailed(transfer, error.Abort, true); break :blk; } } transfer.req.done_callback(transfer.ctx) catch |err| { // transfer isn't valid at this point, don't use it. log.err(.http, "done_callback", .{ .err = err }); requestFailed(transfer, err, true); continue; }; transfer.req.notification.dispatch(.http_request_done, &.{ .transfer = transfer, }); processed = true; } } return processed; } fn endTransfer(self: *Client, transfer: *Transfer) void { const conn = transfer._conn.?; self.removeConn(conn); transfer._conn = null; self.active -= 1; } fn removeConn(self: *Client, conn: *Net.Connection) void { self.in_use.remove(&conn.node); if (self.handles.remove(conn)) { self.releaseConn(conn); } else |_| { // Can happen if we're in a perform() call, so we'll queue this // for cleanup later. self.dirty.append(&conn.node); } } fn releaseConn(self: *Client, conn: *Net.Connection) void { self.network.releaseConnection(conn); } fn ensureNoActiveConnection(self: *const Client) !void { if (self.active > 0) { return error.InflightConnection; } } pub const RequestCookie = struct { is_http: bool, jar: *CookieJar, is_navigation: bool, origin: [:0]const u8, pub fn headersForRequest(self: *const RequestCookie, temp: Allocator, url: [:0]const u8, headers: *Net.Headers) !void { var arr: std.ArrayList(u8) = .{}; try self.jar.forRequest(url, arr.writer(temp), .{ .is_http = self.is_http, .is_navigation = self.is_navigation, .origin_url = self.origin, }); if (arr.items.len > 0) { try arr.append(temp, 0); //null terminate headers.cookies = @as([*c]const u8, @ptrCast(arr.items.ptr)); } } }; pub const Request = struct { frame_id: u32, method: Method, url: [:0]const u8, headers: Net.Headers, body: ?[]const u8 = null, cookie_jar: ?*CookieJar, resource_type: ResourceType, credentials: ?[:0]const u8 = null, notification: *Notification, max_response_size: ?usize = null, // This is only relevant for intercepted requests. If a request is flagged // as blocking AND is intercepted, then it'll be up to us to wait until // we receive a response to the interception. This probably isn't ideal, // but it's harder for our caller (ScriptManager) to deal with this. One // reason for that is the Http Client is already a bit CDP-aware. blocking: bool = false, // arbitrary data that can be associated with this request ctx: *anyopaque = undefined, start_callback: ?*const fn (transfer: *Transfer) anyerror!void = null, header_callback: *const fn (transfer: *Transfer) anyerror!bool, data_callback: *const fn (transfer: *Transfer, data: []const u8) anyerror!void, done_callback: *const fn (ctx: *anyopaque) anyerror!void, error_callback: *const fn (ctx: *anyopaque, err: anyerror) void, shutdown_callback: ?*const fn (ctx: *anyopaque) void = null, const ResourceType = enum { document, xhr, script, fetch, // Allowed Values: Document, Stylesheet, Image, Media, Font, Script, // TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest, // SignedExchange, Ping, CSPViolationReport, Preflight, FedCM, Other // https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ResourceType pub fn string(self: ResourceType) []const u8 { return switch (self) { .document => "Document", .xhr => "XHR", .script => "Script", .fetch => "Fetch", }; } }; }; const AuthChallenge = Net.AuthChallenge; pub const Transfer = struct { arena: ArenaAllocator, id: u32 = 0, req: Request, url: [:0]const u8, ctx: *anyopaque, // copied from req.ctx to make it easier for callback handlers client: *Client, // total bytes received in the response, including the response status line, // the headers, and the [encoded] body. bytes_received: usize = 0, aborted: bool = false, max_response_size: ?usize = null, // We'll store the response header here response_header: ?ResponseHead = null, // track if the header callbacks done have been called. _header_done_called: bool = false, _notified_fail: bool = false, _conn: ?*Net.Connection = null, _redirecting: bool = false, _auth_challenge: ?AuthChallenge = null, // number of times the transfer has been tried. // incremented by reset func. _tries: u8 = 0, _performing: bool = false, // for when a Transfer is queued in the client.queue _node: std.DoublyLinkedList.Node = .{}, _intercept_state: InterceptState = .not_intercepted, const InterceptState = union(enum) { not_intercepted, pending, @"continue", abort: anyerror, fulfilled, }; pub fn reset(self: *Transfer) void { // There's an assertion in ScriptManager that's failing. Seemingly because // the headerCallback is being called multiple times. This shouldn't be // possible (hence the assertion). Previously, this `reset` would set // _header_done_called = false. That could have been how headerCallback // was called multuple times (because _header_done_called is the guard // against that, so resetting it would allow a 2nd call to headerCallback). // But it should also be impossible for this to be true. So, I've added // this assertion to try to narrow down what's going on. lp.assert(self._header_done_called == false, "Transfer.reset header_done_called", .{}); self._redirecting = false; self._auth_challenge = null; self._notified_fail = false; self.response_header = null; self.bytes_received = 0; self._tries += 1; } fn deinit(self: *Transfer) void { self.req.headers.deinit(); if (self._conn) |conn| { self.client.removeConn(conn); } self.arena.deinit(); self.client.transfer_pool.destroy(self); } fn buildResponseHeader(self: *Transfer, conn: *const Net.Connection) !void { if (comptime IS_DEBUG) { std.debug.assert(self.response_header == null); } const url = try conn.getEffectiveUrl(); const status: u16 = if (self._auth_challenge != null) 407 else try conn.getResponseCode(); self.response_header = .{ .url = url, .status = status, .redirect_count = try conn.getRedirectCount(), }; if (conn.getResponseHeader("content-type", 0)) |ct| { var hdr = &self.response_header.?; const value = ct.value; const len = @min(value.len, ResponseHead.MAX_CONTENT_TYPE_LEN); hdr._content_type_len = len; @memcpy(hdr._content_type[0..len], value[0..len]); } } pub fn format(self: *Transfer, writer: *std.Io.Writer) !void { const req = self.req; return writer.print("{s} {s}", .{ @tagName(req.method), req.url }); } pub fn updateURL(self: *Transfer, url: [:0]const u8) !void { // for cookies self.url = url; // for the request itself self.req.url = url; } pub fn updateCredentials(self: *Transfer, userpwd: [:0]const u8) void { self.req.credentials = userpwd; } pub fn replaceRequestHeaders(self: *Transfer, allocator: Allocator, headers: []const Net.Header) !void { self.req.headers.deinit(); var buf: std.ArrayList(u8) = .empty; var new_headers = try self.client.newHeaders(); for (headers) |hdr| { // safe to re-use this buffer, because Headers.add because curl copies // the value we pass into curl_slist_append. defer buf.clearRetainingCapacity(); try std.fmt.format(buf.writer(allocator), "{s}: {s}", .{ hdr.name, hdr.value }); try buf.append(allocator, 0); // null terminated try new_headers.add(buf.items[0 .. buf.items.len - 1 :0]); } self.req.headers = new_headers; } pub fn abort(self: *Transfer, err: anyerror) void { requestFailed(self, err, true); const client = self.client; if (self._performing or client.performing) { // We're currently in a curl_multi_perform. We cannot call endTransfer // as that calls curl_multi_remove_handle, and you can't do that // from a curl callback. Instead, we flag this transfer and all of // our callbacks will check for this flag and abort the transfer for // us self.aborted = true; return; } if (self._conn != null) { client.endTransfer(self); } self.deinit(); } pub fn terminate(self: *Transfer) void { requestFailed(self, error.Shutdown, false); if (self._conn != null) { self.client.endTransfer(self); } self.deinit(); } // internal, when the page is shutting down. Doesn't have the same ceremony // as abort (doesn't send a notification, doesn't invoke an error callback) fn kill(self: *Transfer) void { if (self._conn != null) { self.client.endTransfer(self); } if (self.req.shutdown_callback) |cb| { cb(self.ctx); } self.deinit(); } // abortAuthChallenge is called when an auth challenge interception is // abort. We don't call self.client.endTransfer here b/c it has been done // before interception process. pub fn abortAuthChallenge(self: *Transfer) void { if (comptime IS_DEBUG) { std.debug.assert(self._intercept_state != .not_intercepted); log.debug(.http, "abort auth transfer", .{ .intercepted = self.client.intercepted }); } self.client.intercepted -= 1; if (!self.req.blocking) { self.abort(error.AbortAuthChallenge); return; } self._intercept_state = .{ .abort = error.AbortAuthChallenge }; } // redirectionCookies manages cookies during redirections handled by Curl. // It sets the cookies from the current response to the cookie jar. // It also immediately sets cookies for the following request. fn redirectionCookies(transfer: *Transfer, conn: *const Net.Connection) !void { const req = &transfer.req; const arena = transfer.arena.allocator(); // retrieve cookies from the redirect's response. if (req.cookie_jar) |jar| { var i: usize = 0; while (true) { const ct = conn.getResponseHeader("set-cookie", i); if (ct == null) break; try jar.populateFromResponse(transfer.url, ct.?.value); i += 1; if (i >= ct.?.amount) break; } } // set cookies for the following redirection's request. const location = conn.getResponseHeader("location", 0) orelse { return error.LocationNotFound; }; const base_url = try conn.getEffectiveUrl(); const url = try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); transfer.url = url; if (req.cookie_jar) |jar| { var cookies: std.ArrayList(u8) = .{}; try jar.forRequest(url, cookies.writer(arena), .{ .is_http = true, .origin_url = url, // used to enforce samesite cookie rules .is_navigation = req.resource_type == .document, }); try cookies.append(arena, 0); //null terminate try conn.setCookies(@ptrCast(cookies.items.ptr)); } } // headerDoneCallback is called once the headers have been read. // It can be called either on dataCallback or once the request for those // w/o body. fn headerDoneCallback(transfer: *Transfer, conn: *const Net.Connection) !bool { lp.assert(transfer._header_done_called == false, "Transfer.headerDoneCallback", .{}); defer transfer._header_done_called = true; try transfer.buildResponseHeader(conn); if (conn.getResponseHeader("content-type", 0)) |ct| { var hdr = &transfer.response_header.?; const value = ct.value; const len = @min(value.len, ResponseHead.MAX_CONTENT_TYPE_LEN); hdr._content_type_len = len; @memcpy(hdr._content_type[0..len], value[0..len]); } if (transfer.req.cookie_jar) |jar| { var i: usize = 0; while (true) { const ct = conn.getResponseHeader("set-cookie", i); if (ct == null) break; jar.populateFromResponse(transfer.url, ct.?.value) catch |err| { log.err(.http, "set cookie", .{ .err = err, .req = transfer }); return err; }; i += 1; if (i >= ct.?.amount) break; } } if (transfer.max_response_size) |max_size| { if (transfer.getContentLength()) |cl| { if (cl > max_size) { return error.ResponseTooLarge; } } } const proceed = transfer.req.header_callback(transfer) catch |err| { log.err(.http, "header_callback", .{ .err = err, .req = transfer }); return err; }; transfer.req.notification.dispatch(.http_response_header_done, &.{ .transfer = transfer, }); return proceed and transfer.aborted == false; } // headerCallback is called by curl on each request's header line read. fn headerCallback(buffer: [*]const u8, header_count: usize, buf_len: usize, data: *anyopaque) usize { // libcurl should only ever emit 1 header at a time if (comptime IS_DEBUG) { std.debug.assert(header_count == 1); } const conn: Net.Connection = .{ .easy = @ptrCast(@alignCast(data)) }; var transfer = fromConnection(&conn) catch |err| { log.err(.http, "get private info", .{ .err = err, .source = "header callback" }); return 0; }; if (comptime IS_DEBUG) { // curl will allow header lines that end with either \r\n or just \n std.debug.assert(buffer[buf_len - 1] == '\n'); } if (buf_len < 3) { // could be \r\n or \n. // We get the last header line. if (transfer._redirecting) { // parse and set cookies for the redirection. redirectionCookies(transfer, &conn) catch |err| { if (comptime IS_DEBUG) { log.debug(.http, "redirection cookies", .{ .err = err }); } return 0; }; } return buf_len; } var header_len = buf_len - 2; if (buffer[buf_len - 2] != '\r') { // curl supports headers that just end with either \r\n or \n header_len = buf_len - 1; } const header = buffer[0..header_len]; // We need to parse the first line headers for each request b/c curl's // CURLINFO_RESPONSE_CODE returns the status code of the final request. // If a redirection or a proxy's CONNECT forbidden happens, we won't // get this intermediary status code. if (std.mem.startsWith(u8, header, "HTTP/")) { // Is it the first header line. if (buf_len < 13) { if (comptime IS_DEBUG) { log.debug(.http, "invalid response line", .{ .line = header }); } return 0; } const version_start: usize = if (header[5] == '2') 7 else 9; const version_end = version_start + 3; // a bit silly, but it makes sure that we don't change the length check // above in a way that could break this. if (comptime IS_DEBUG) { std.debug.assert(version_end < 13); } const status = std.fmt.parseInt(u16, header[version_start..version_end], 10) catch { if (comptime IS_DEBUG) { log.debug(.http, "invalid status code", .{ .line = header }); } return 0; }; if (status >= 300 and status <= 399) { transfer._redirecting = true; return buf_len; } transfer._redirecting = false; if (status == 401 or status == 407) { // The auth challenge must be parsed from a following // WWW-Authenticate or Proxy-Authenticate header. transfer._auth_challenge = .{ .status = status, .source = null, .scheme = null, .realm = null, }; return buf_len; } transfer._auth_challenge = null; transfer.bytes_received = buf_len; return buf_len; } if (transfer._redirecting == false and transfer._auth_challenge != null) { transfer.bytes_received += buf_len; } if (transfer._auth_challenge != null) { // try to parse auth challenge. if (std.ascii.startsWithIgnoreCase(header, "WWW-Authenticate") or std.ascii.startsWithIgnoreCase(header, "Proxy-Authenticate")) { const ac = AuthChallenge.parse( transfer._auth_challenge.?.status, header, ) catch |err| { // We can't parse the auth challenge log.err(.http, "parse auth challenge", .{ .err = err, .header = header }); // Should we cancel the request? I don't think so. return buf_len; }; transfer._auth_challenge = ac; } } return buf_len; } fn dataCallback(buffer: [*]const u8, chunk_count: usize, chunk_len: usize, data: *anyopaque) usize { // libcurl should only ever emit 1 chunk at a time if (comptime IS_DEBUG) { std.debug.assert(chunk_count == 1); } const conn: Net.Connection = .{ .easy = @ptrCast(@alignCast(data)) }; var transfer = fromConnection(&conn) catch |err| { log.err(.http, "get private info", .{ .err = err, .source = "body callback" }); return Net.writefunc_error; }; if (transfer._redirecting or transfer._auth_challenge != null) { return @intCast(chunk_len); } if (!transfer._header_done_called) { const proceed = transfer.headerDoneCallback(&conn) catch |err| { log.err(.http, "header_done_callback", .{ .err = err, .req = transfer }); return Net.writefunc_error; }; if (!proceed) { // signal abort to libcurl return Net.writefunc_error; } } transfer.bytes_received += chunk_len; if (transfer.max_response_size) |max_size| { if (transfer.bytes_received > max_size) { requestFailed(transfer, error.ResponseTooLarge, true); return Net.writefunc_error; } } const chunk = buffer[0..chunk_len]; transfer.req.data_callback(transfer, chunk) catch |err| { log.err(.http, "data_callback", .{ .err = err, .req = transfer }); return Net.writefunc_error; }; transfer.req.notification.dispatch(.http_response_data, &.{ .data = chunk, .transfer = transfer, }); if (transfer.aborted) { return Net.writefunc_error; } return @intCast(chunk_len); } pub fn responseHeaderIterator(self: *Transfer) HeaderIterator { if (self._conn) |conn| { // If we have a connection, than this is a real curl request and we // iterate through the header that curl maintains. return .{ .curl = .{ .conn = conn } }; } // If there's no handle, it either means this is being called before // the request is even being made (which would be a bug in the code) // or when a response was injected via transfer.fulfill. The injected // header should be iterated, since there is no handle/easy. return .{ .list = .{ .list = self.response_header.?._injected_headers } }; } pub fn fromConnection(conn: *const Net.Connection) !*Transfer { const private = try conn.getPrivate(); return @ptrCast(@alignCast(private)); } pub fn fulfill(transfer: *Transfer, status: u16, headers: []const Net.Header, body: ?[]const u8) !void { if (transfer._conn != null) { // should never happen, should have been intercepted/paused, and then // either continued, aborted or fulfilled once. @branchHint(.unlikely); return error.RequestInProgress; } transfer._fulfill(status, headers, body) catch |err| { transfer.req.error_callback(transfer.req.ctx, err); return err; }; } fn _fulfill(transfer: *Transfer, status: u16, headers: []const Net.Header, body: ?[]const u8) !void { const req = &transfer.req; if (req.start_callback) |cb| { try cb(transfer); } transfer.response_header = .{ .status = status, .url = req.url, .redirect_count = 0, ._injected_headers = headers, }; for (headers) |hdr| { if (std.ascii.eqlIgnoreCase(hdr.name, "content-type")) { const len = @min(hdr.value.len, ResponseHead.MAX_CONTENT_TYPE_LEN); @memcpy(transfer.response_header.?._content_type[0..len], hdr.value[0..len]); transfer.response_header.?._content_type_len = len; break; } } lp.assert(transfer._header_done_called == false, "Transfer.fulfill header_done_called", .{}); if (try req.header_callback(transfer) == false) { transfer.abort(error.Abort); return; } if (body) |b| { try req.data_callback(transfer, b); } try req.done_callback(req.ctx); } // This function should be called during the dataCallback. Calling it after // such as in the doneCallback is guaranteed to return null. pub fn getContentLength(self: *const Transfer) ?u32 { const cl = self.getContentLengthRawValue() orelse return null; return std.fmt.parseInt(u32, cl, 10) catch null; } fn getContentLengthRawValue(self: *const Transfer) ?[]const u8 { if (self._conn) |conn| { // If we have a connection, than this is a normal request. We can get the // header value from the connection. const cl = conn.getResponseHeader("content-length", 0) orelse return null; return cl.value; } // If we have no handle, then maybe this is being called after the // doneCallback. OR, maybe this is a "fulfilled" request. Let's check // the injected headers (if we have any). const rh = self.response_header orelse return null; for (rh._injected_headers) |hdr| { if (std.ascii.eqlIgnoreCase(hdr.name, "content-length")) { return hdr.value; } } return null; } }; ================================================ FILE: src/browser/Mime.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Mime = @This(); content_type: ContentType, params: []const u8 = "", // IANA defines max. charset value length as 40. // We keep 41 for null-termination since HTML parser expects in this format. charset: [41]u8 = default_charset, charset_len: usize = default_charset_len, is_default_charset: bool = true, /// String "UTF-8" continued by null characters. const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; const default_charset_len = 5; /// Mime with unknown Content-Type, empty params and empty charset. pub const unknown = Mime{ .content_type = .{ .unknown = {} } }; pub const ContentTypeEnum = enum { text_xml, text_html, text_javascript, text_plain, text_css, image_jpeg, image_gif, image_png, image_webp, application_json, unknown, other, }; pub const ContentType = union(ContentTypeEnum) { text_xml: void, text_html: void, text_javascript: void, text_plain: void, text_css: void, image_jpeg: void, image_gif: void, image_png: void, image_webp: void, application_json: void, unknown: void, other: struct { type: []const u8, sub_type: []const u8 }, }; pub fn contentTypeString(mime: *const Mime) []const u8 { return switch (mime.content_type) { .text_xml => "text/xml", .text_html => "text/html", .text_javascript => "application/javascript", .text_plain => "text/plain", .text_css => "text/css", .image_jpeg => "image/jpeg", .image_png => "image/png", .image_gif => "image/gif", .image_webp => "image/webp", .application_json => "application/json", else => "", }; } /// Returns the null-terminated charset value. pub fn charsetStringZ(mime: *const Mime) [:0]const u8 { return mime.charset[0..mime.charset_len :0]; } pub fn charsetString(mime: *const Mime) []const u8 { return mime.charset[0..mime.charset_len]; } /// Removes quotes of value if quotes are given. /// /// Currently we don't validate the charset. /// See section 2.3 Naming Requirements: /// https://datatracker.ietf.org/doc/rfc2978/ fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 { // Cannot be larger than 40. // https://datatracker.ietf.org/doc/rfc2978/ if (value.len > 40) return error.CharsetTooBig; // If the first char is a quote, look for a pair. if (value[0] == '"') { if (value.len < 3 or value[value.len - 1] != '"') { return error.Invalid; } return value[1 .. value.len - 1]; } // No quotes. return value; } pub fn parse(input: []u8) !Mime { if (input.len > 255) { return error.TooBig; } // Zig's trim API is broken. The return type is always `[]const u8`, // even if the input type is `[]u8`. @constCast is safe here. var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace)); _ = std.ascii.lowerString(normalized, normalized); const content_type, const type_len = try parseContentType(normalized); if (type_len >= normalized.len) { return .{ .content_type = content_type }; } const params = trimLeft(normalized[type_len..]); var charset: [41]u8 = default_charset; var charset_len: usize = default_charset_len; var has_explicit_charset = false; var it = std.mem.splitScalar(u8, params, ';'); while (it.next()) |attr| { const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue; const name = trimLeft(attr[0..i]); const value = trimRight(attr[i + 1 ..]); if (value.len == 0) { continue; } const attribute_name = std.meta.stringToEnum(enum { charset, }, name) orelse continue; switch (attribute_name) { .charset => { if (value.len == 0) { break; } const attribute_value = parseCharset(value) catch continue; @memcpy(charset[0..attribute_value.len], attribute_value); // Null-terminate right after attribute value. charset[attribute_value.len] = 0; charset_len = attribute_value.len; has_explicit_charset = true; }, } } return .{ .params = params, .charset = charset, .charset_len = charset_len, .content_type = content_type, .is_default_charset = !has_explicit_charset, }; } /// Prescan the first 1024 bytes of an HTML document for a charset declaration. /// Looks for `` and ``. /// Returns the charset value or null if none found. /// See: https://www.w3.org/International/questions/qa-html-encoding-declarations pub fn prescanCharset(html: []const u8) ?[]const u8 { const limit = @min(html.len, 1024); const data = html[0..limit]; // Scan for = data.len) return null; // Check for "meta" (case-insensitive) if (pos + 4 >= data.len) return null; var tag_buf: [4]u8 = undefined; _ = std.ascii.lowerString(&tag_buf, data[pos..][0..4]); if (!std.mem.eql(u8, &tag_buf, "meta")) { continue; } pos += 4; // Must be followed by whitespace or end of tag if (pos >= data.len) return null; if (data[pos] != ' ' and data[pos] != '\t' and data[pos] != '\n' and data[pos] != '\r' and data[pos] != '/') { continue; } // Scan attributes within this meta tag const tag_end = std.mem.indexOfScalarPos(u8, data, pos, '>') orelse return null; const attrs = data[pos..tag_end]; // Look for charset= attribute directly if (findAttrValue(attrs, "charset")) |charset| { if (charset.len > 0 and charset.len <= 40) return charset; } // Look for http-equiv="content-type" with content="...;charset=X" if (findAttrValue(attrs, "http-equiv")) |he| { if (std.ascii.eqlIgnoreCase(he, "content-type")) { if (findAttrValue(attrs, "content")) |content| { if (extractCharsetFromContentType(content)) |charset| { return charset; } } } } pos = tag_end + 1; } return null; } fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 { var pos: usize = 0; while (pos < attrs.len) { // Skip whitespace while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t' or attrs[pos] == '\n' or attrs[pos] == '\r')) { pos += 1; } if (pos >= attrs.len) return null; // Read attribute name const attr_start = pos; while (pos < attrs.len and attrs[pos] != '=' and attrs[pos] != ' ' and attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/') { pos += 1; } const attr_name = attrs[attr_start..pos]; // Skip whitespace around = while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1; if (pos >= attrs.len or attrs[pos] != '=') { // No '=' found - skip this token. Advance at least one byte to avoid infinite loop. if (pos == attr_start) pos += 1; continue; } pos += 1; // skip '=' while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1; if (pos >= attrs.len) return null; // Read attribute value const value = blk: { if (attrs[pos] == '"' or attrs[pos] == '\'') { const quote = attrs[pos]; pos += 1; const val_start = pos; while (pos < attrs.len and attrs[pos] != quote) pos += 1; const val = attrs[val_start..pos]; if (pos < attrs.len) pos += 1; // skip closing quote break :blk val; } else { const val_start = pos; while (pos < attrs.len and attrs[pos] != ' ' and attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/') { pos += 1; } break :blk attrs[val_start..pos]; } }; if (std.ascii.eqlIgnoreCase(attr_name, name)) return value; } return null; } fn extractCharsetFromContentType(content: []const u8) ?[]const u8 { var it = std.mem.splitScalar(u8, content, ';'); while (it.next()) |part| { const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' }); if (trimmed.len > 8 and std.ascii.eqlIgnoreCase(trimmed[0..8], "charset=")) { const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' }); if (val.len > 0 and val.len <= 40) return val; } } return null; } pub fn sniff(body: []const u8) ?Mime { // 0x0C is form feed const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C }); if (content.len == 0) { return null; } if (content[0] != '<') { if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) { // UTF-8 BOM return .{ .content_type = .{ .text_plain = {} }, .charset = default_charset, .charset_len = default_charset_len, .is_default_charset = false, }; } if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) { // UTF-16 big-endian BOM return .{ .content_type = .{ .text_plain = {} }, .charset = .{ 'U', 'T', 'F', '-', '1', '6', 'B', 'E' } ++ .{0} ** 33, .charset_len = 8, .is_default_charset = false, }; } if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) { // UTF-16 little-endian BOM return .{ .content_type = .{ .text_plain = {} }, .charset = .{ 'U', 'T', 'F', '-', '1', '6', 'L', 'E' } ++ .{0} ** 33, .charset_len = 8, .is_default_charset = false, }; } return null; } // The longest prefix we have is " known_prefix.len) { const next = prefix[known_prefix.len]; // a "tag-terminating-byte" if (next == ' ' or next == '>') { return .{ .content_type = kp.@"1" }; } } } return null; } pub fn isHTML(self: *const Mime) bool { return self.content_type == .text_html; } // we expect value to be lowercase fn parseContentType(value: []const u8) !struct { ContentType, usize } { const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len; const type_name = trimRight(value[0..end]); const attribute_start = end + 1; if (std.meta.stringToEnum(enum { @"text/xml", @"text/html", @"text/css", @"text/plain", @"text/javascript", @"application/javascript", @"application/x-javascript", @"image/jpeg", @"image/png", @"image/gif", @"image/webp", @"application/json", }, type_name)) |known_type| { const ct: ContentType = switch (known_type) { .@"text/xml" => .{ .text_xml = {} }, .@"text/html" => .{ .text_html = {} }, .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} }, .@"text/plain" => .{ .text_plain = {} }, .@"text/css" => .{ .text_css = {} }, .@"image/jpeg" => .{ .image_jpeg = {} }, .@"image/png" => .{ .image_png = {} }, .@"image/gif" => .{ .image_gif = {} }, .@"image/webp" => .{ .image_webp = {} }, .@"application/json" => .{ .application_json = {} }, }; return .{ ct, attribute_start }; } const separator = std.mem.indexOfScalarPos(u8, type_name, 0, '/') orelse return error.Invalid; const main_type = value[0..separator]; const sub_type = trimRight(value[separator + 1 .. end]); if (main_type.len == 0 or validType(main_type) == false) { return error.Invalid; } if (sub_type.len == 0 or validType(sub_type) == false) { return error.Invalid; } return .{ .{ .other = .{ .type = main_type, .sub_type = sub_type, } }, attribute_start }; } const VALID_CODEPOINTS = blk: { var v: [256]bool = undefined; for (0..256) |i| { v[i] = std.ascii.isAlphanumeric(i); } for ("!#$%&\\*+-.^'_`|~") |b| { v[b] = true; } break :blk v; }; fn validType(value: []const u8) bool { for (value) |b| { if (VALID_CODEPOINTS[b] == false) { return false; } } return true; } fn trimLeft(s: []const u8) []const u8 { return std.mem.trimLeft(u8, s, &std.ascii.whitespace); } fn trimRight(s: []const u8) []const u8 { return std.mem.trimRight(u8, s, &std.ascii.whitespace); } const testing = @import("../testing.zig"); test "Mime: invalid" { defer testing.reset(); const invalids = [_][]const u8{ "", "text", "text /html", "text/ html", "text / html", "text/html other", }; for (invalids) |invalid| { const mutable_input = try testing.arena_allocator.dupe(u8, invalid); try testing.expectError(error.Invalid, Mime.parse(mutable_input)); } } test "Mime: malformed parameters are ignored" { defer testing.reset(); // These should all parse successfully as text/html with malformed params ignored const valid_with_malformed_params = [_][]const u8{ "text/html; x", "text/html; x=", "text/html; x= ", "text/html; = ", "text/html;=", "text/html; charset=\"\"", "text/html; charset=\"", "text/html; charset=\"\\", "text/html;\"", }; for (valid_with_malformed_params) |input| { const mutable_input = try testing.arena_allocator.dupe(u8, input); const mime = try Mime.parse(mutable_input); try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type)); } } test "Mime: parse common" { defer testing.reset(); try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml"); try expect(.{ .content_type = .{ .text_html = {} } }, "text/html"); try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain"); try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml;"); try expect(.{ .content_type = .{ .text_html = {} } }, "text/html;"); try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain;"); try expect(.{ .content_type = .{ .text_xml = {} } }, " \ttext/xml"); try expect(.{ .content_type = .{ .text_html = {} } }, "text/html "); try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain \t\t"); try expect(.{ .content_type = .{ .text_xml = {} } }, "TEXT/xml"); try expect(.{ .content_type = .{ .text_html = {} } }, "text/Html"); try expect(.{ .content_type = .{ .text_plain = {} } }, "TEXT/PLAIN"); try expect(.{ .content_type = .{ .text_xml = {} } }, " TeXT/xml"); try expect(.{ .content_type = .{ .text_html = {} } }, "teXt/HtML ;"); try expect(.{ .content_type = .{ .text_plain = {} } }, "tExT/PlAiN;"); try expect(.{ .content_type = .{ .text_javascript = {} } }, "text/javascript"); try expect(.{ .content_type = .{ .text_javascript = {} } }, "Application/JavaScript"); try expect(.{ .content_type = .{ .text_javascript = {} } }, "application/x-javascript"); try expect(.{ .content_type = .{ .application_json = {} } }, "application/json"); try expect(.{ .content_type = .{ .text_css = {} } }, "text/css"); try expect(.{ .content_type = .{ .image_jpeg = {} } }, "image/jpeg"); try expect(.{ .content_type = .{ .image_png = {} } }, "image/png"); try expect(.{ .content_type = .{ .image_gif = {} } }, "image/gif"); try expect(.{ .content_type = .{ .image_webp = {} } }, "image/webp"); } test "Mime: parse uncommon" { defer testing.reset(); const text_csv = Expectation{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } }, }; try expect(text_csv, "text/csv"); try expect(text_csv, "text/csv;"); try expect(text_csv, " text/csv\t "); try expect(text_csv, " text/csv\t ;"); try expect( .{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } } }, "Text/CSV", ); } test "Mime: parse charset" { defer testing.reset(); try expect(.{ .content_type = .{ .text_xml = {} }, .charset = "utf-8", .params = "charset=utf-8", }, "text/xml; charset=utf-8"); try expect(.{ .content_type = .{ .text_xml = {} }, .charset = "utf-8", .params = "charset=\"utf-8\"", }, "text/xml;charset=\"UTF-8\""); try expect(.{ .content_type = .{ .text_html = {} }, .charset = "iso-8859-1", .params = "charset=\"iso-8859-1\"", }, "text/html; charset=\"iso-8859-1\""); try expect(.{ .content_type = .{ .text_html = {} }, .charset = "iso-8859-1", .params = "charset=\"iso-8859-1\"", }, "text/html; charset=\"ISO-8859-1\""); try expect(.{ .content_type = .{ .text_xml = {} }, .charset = "custom-non-standard-charset-value", .params = "charset=\"custom-non-standard-charset-value\"", }, "text/xml;charset=\"custom-non-standard-charset-value\""); try expect(.{ .content_type = .{ .text_html = {} }, .charset = "UTF-8", .params = "x=\"", }, "text/html;x=\""); } test "Mime: isHTML" { defer testing.reset(); const assert = struct { fn assert(expected: bool, input: []const u8) !void { const mutable_input = try testing.arena_allocator.dupe(u8, input); var mime = try Mime.parse(mutable_input); try testing.expectEqual(expected, mime.isHTML()); } }.assert; try assert(true, "text/html"); try assert(true, "text/html;"); try assert(true, "text/html; charset=utf-8"); try assert(false, "text/htm"); // htm not html try assert(false, "text/plain"); try assert(false, "over/9000"); } test "Mime: sniff" { try testing.expectEqual(null, Mime.sniff("")); try testing.expectEqual(null, Mime.sniff("")); try testing.expectEqual(null, Mime.sniff("\n ")); try testing.expectEqual(null, Mime.sniff("\n \t ")); const expectHTML = struct { fn expect(input: []const u8) !void { try testing.expectEqual(.text_html, std.meta.activeTag(Mime.sniff(input).?.content_type)); } }.expect; try expectHTML(" even more stufff"); try expectHTML(""); try expectHTML(" ================================================ FILE: src/browser/tests/frames/post_message.html ================================================ ================================================ FILE: src/browser/tests/frames/support/after_link.html ================================================ It was clicked! ================================================ FILE: src/browser/tests/frames/support/message_receiver.html ================================================ ================================================ FILE: src/browser/tests/frames/support/page.html ================================================ a-page ================================================ FILE: src/browser/tests/frames/support/sub 1.html ================================================
sub1 div1
================================================ FILE: src/browser/tests/frames/support/sub2.html ================================================
sub2 div1
================================================ FILE: src/browser/tests/frames/support/with_link.html ================================================ a link ================================================ FILE: src/browser/tests/frames/target.html ================================================
================================================ FILE: src/browser/tests/history.html ================================================ ================================================ FILE: src/browser/tests/history_after_nav.skip.html ================================================ ================================================ FILE: src/browser/tests/image_data.html ================================================ ================================================ FILE: src/browser/tests/integration/custom_element_composition.html ================================================ ================================================ FILE: src/browser/tests/intersection_observer/basic.html ================================================
Target Element
================================================ FILE: src/browser/tests/intersection_observer/disconnect.html ================================================
Target Element
================================================ FILE: src/browser/tests/intersection_observer/multiple_targets.html ================================================
Target 1
Target 2
================================================ FILE: src/browser/tests/intersection_observer/unobserve.html ================================================
Target 1
Target 2
================================================ FILE: src/browser/tests/legacy/browser.html ================================================ ================================================ FILE: src/browser/tests/legacy/crypto.html ================================================ ================================================ FILE: src/browser/tests/legacy/css.html ================================================ ================================================ FILE: src/browser/tests/legacy/cssom/css_style_declaration.html ================================================ ================================================ FILE: src/browser/tests/legacy/cssom/css_stylesheet.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/animation.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/attribute.html ================================================ OK ================================================ FILE: src/browser/tests/legacy/dom/character_data.html ================================================ OK ================================================ FILE: src/browser/tests/legacy/dom/comment.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/document.html ================================================
OK

And

================================================ FILE: src/browser/tests/legacy/dom/document_fragment.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/document_type.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/dom_parser.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/element.html ================================================
OK

And

content

--> ================================================ FILE: src/browser/tests/legacy/dom/event_target.html ================================================

================================================ FILE: src/browser/tests/legacy/dom/exceptions.html ================================================
OK
================================================ FILE: src/browser/tests/legacy/dom/html_collection.html ================================================
OK

And

================================================ FILE: src/browser/tests/legacy/dom/implementation.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/intersection_observer.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/named_node_map.html ================================================
================================================ FILE: src/browser/tests/legacy/dom/node_filter.html ================================================
Text content
================================================ FILE: src/browser/tests/legacy/dom/node_list.html ================================================
OK

And

================================================ FILE: src/browser/tests/legacy/dom/node_owner.html ================================================

I am the original reference node.

================================================ FILE: src/browser/tests/legacy/dom/performance.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/performance_observer.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/processing_instruction.html ================================================ ================================================ FILE: src/browser/tests/legacy/dom/range.html ================================================

over 9000

================================================ FILE: src/browser/tests/legacy/dom/text.html ================================================ OK ================================================ FILE: src/browser/tests/legacy/dom/token_list.html ================================================

================================================ FILE: src/browser/tests/legacy/encoding/decoder.html ================================================ ================================================ FILE: src/browser/tests/legacy/encoding/encoder.html ================================================ ================================================ FILE: src/browser/tests/legacy/events/composition.html ================================================ ================================================ FILE: src/browser/tests/legacy/events/custom.html ================================================ ================================================ FILE: src/browser/tests/legacy/events/event.html ================================================

================================================ FILE: src/browser/tests/legacy/events/keyboard.html ================================================ ================================================ FILE: src/browser/tests/legacy/events/mouse.html ================================================ ================================================ FILE: src/browser/tests/legacy/fetch/fetch.html ================================================ ================================================ FILE: src/browser/tests/legacy/fetch/headers.html ================================================ ================================================ FILE: src/browser/tests/legacy/fetch/request.html ================================================ ================================================ FILE: src/browser/tests/legacy/fetch/response.html ================================================ ================================================ FILE: src/browser/tests/legacy/file/blob.html ================================================ ================================================ FILE: src/browser/tests/legacy/file/file.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/abort_controller.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/canvas.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/dataset.html ================================================
================================================ FILE: src/browser/tests/legacy/html/document.html ================================================
================================================ FILE: src/browser/tests/legacy/html/element.html ================================================
abcc
================================================ FILE: src/browser/tests/legacy/html/error_event.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/history/history.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/history/history2.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/history/history_after_nav.skip.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/image.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/input.html ================================================

================================================ FILE: src/browser/tests/legacy/html/link.html ================================================ OK ================================================ FILE: src/browser/tests/legacy/html/location.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/navigation/navigation.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/navigation/navigation_after_nav.skip.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/navigation/navigation_currententrychange.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/navigator.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/screen.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/script/dynamic_import.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/script/import.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/script/import.js ================================================ let greeting = 'hello'; export {greeting as 'greeting'}; ================================================ FILE: src/browser/tests/legacy/html/script/import2.js ================================================ let greeting = 'world'; export {greeting as 'greeting'}; ================================================ FILE: src/browser/tests/legacy/html/script/importmap.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/script/inline_defer.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/script/inline_defer.js ================================================ dyn1_loaded += 1; ================================================ FILE: src/browser/tests/legacy/html/script/order.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/script/order.js ================================================ list += 'a'; testing.expectEqual('a', list); ================================================ FILE: src/browser/tests/legacy/html/script/order_async.js ================================================ list += 'f'; testing.expectEqual('abcdef', list); ================================================ FILE: src/browser/tests/legacy/html/script/order_defer.js ================================================ list += 'e'; testing.expectEqual('abcde', list); ================================================ FILE: src/browser/tests/legacy/html/script/script.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/select.html ================================================
================================================ FILE: src/browser/tests/legacy/html/slot.html ================================================ default

default

default

xx other
More

default2

!!
hello
hello
hello
================================================ FILE: src/browser/tests/legacy/html/style.html ================================================ ================================================ FILE: src/browser/tests/legacy/html/svg.html ================================================ OVER 9000!! OVER 9000!!! ================================================ FILE: src/browser/tests/legacy/storage/local_storage.html ================================================ ================================================ FILE: src/browser/tests/legacy/streams/readable_stream.html ================================================ ================================================ FILE: src/browser/tests/legacy/testing.js ================================================ // Note: this code tries to make sure that we don't fail to execute a tags we have have had at least // 1 assertion. This helps ensure that if a script tag fails to execute, // we'll report an error, even if no assertions failed. const scripts = document.getElementsByTagName('script'); for (script of scripts) { const id = script.id; if (!id) { continue; } if (!testing._executed_scripts.has(id)) { console.warn(`Failed to execute any expectations for `); throw new Error('Failed'); } } if (testing._status != 'ok') { throw new Error(testing._status); } } // Set expectations to happen at some point in the future. Necessary for // testing callbacks which will only be executed after page.wait is called. function eventually(fn) { // capture the current state (script id, stack) so that, when we do run this // we can display more meaningful details on failure. testing._eventually.push([fn, { script_id: document.currentScript.id, stack: new Error().stack, }]); _registerErrorCallback(); } async function async(promise, cb) { const script_id = document.currentScript ? document.currentScript.id : '.\n There should be a eval error printed above this.`, ); } } function _equal(a, b) { if (a === b) { return true; } if (a === null || b === null) { return false; } if (typeof a !== 'object' || typeof b !== 'object') { return false; } if (Object.keys(a).length != Object.keys(b).length) { return false; } for (property in a) { if (b.hasOwnProperty(property) === false) { return false; } if (_equal(a[property], b[property]) === false) { return false; } } return true; } window.testing = { _status: 'empty', _eventually: [], _executed_scripts: new Set(), _captured: null, skip: skip, async: async, assertOk: assertOk, eventually: eventually, expectEqual: expectEqual, expectError: expectError, withError: withError, }; // Helper, so you can do $(sel) in a test window.$ = function(sel) { return document.querySelector(sel); } // Helper, so you can do $$(sel) in a test window.$$ = function(sel) { return document.querySelectorAll(sel); } if (!console.lp) { // make this work in the browser console.lp = console.log; } })(); ================================================ FILE: src/browser/tests/legacy/url/url.html ================================================ ================================================ FILE: src/browser/tests/legacy/url/url_search_params.html ================================================ ================================================ FILE: src/browser/tests/legacy/window/frames.html ================================================ ================================================ FILE: src/browser/tests/legacy/window/window.html ================================================ ================================================ FILE: src/browser/tests/legacy/xhr/form_data.html ================================================
================================================ FILE: src/browser/tests/legacy/xhr/progress_event.html ================================================ ================================================ FILE: src/browser/tests/legacy/xhr/xhr.html ================================================

And

================================================ FILE: src/browser/tests/mcp_actions.html ================================================
Long content
================================================ FILE: src/browser/tests/media/mediaerror.html ================================================ ================================================ FILE: src/browser/tests/media/vttcue.html ================================================ ================================================ FILE: src/browser/tests/message_channel.html ================================================ ================================================ FILE: src/browser/tests/mutation_observer/attribute_filter.html ================================================
Test
Test
Child
================================================ FILE: src/browser/tests/mutation_observer/character_data.html ================================================
Initial text
Test
Test
================================================ FILE: src/browser/tests/mutation_observer/childlist.html ================================================
Child 1
Only
First
Middle
Last
First
Last
Old
Old 1
Old 2
Old 3
================================================ FILE: src/browser/tests/mutation_observer/multiple_observers.html ================================================
Test
================================================ FILE: src/browser/tests/mutation_observer/mutation_observer.html ================================================
Test
Test
Test
Test
================================================ FILE: src/browser/tests/mutation_observer/mutations_during_callback.html ================================================
Test
================================================ FILE: src/browser/tests/mutation_observer/observe_multiple_targets.html ================================================
Test1
Test2
================================================ FILE: src/browser/tests/mutation_observer/reobserve_same_target.html ================================================
Test
================================================ FILE: src/browser/tests/mutation_observer/subtree.html ================================================
Child
Text here
================================================ FILE: src/browser/tests/navigator/navigator.html ================================================ ================================================ FILE: src/browser/tests/net/fetch.html ================================================ ================================================ FILE: src/browser/tests/net/form_data.html ================================================
================================================ FILE: src/browser/tests/net/headers.html ================================================ ================================================ FILE: src/browser/tests/net/request.html ================================================ ================================================ FILE: src/browser/tests/net/response.html ================================================ ================================================ FILE: src/browser/tests/net/url_search_params.html ================================================ ================================================ FILE: src/browser/tests/net/xhr.html ================================================ ================================================ FILE: src/browser/tests/node/adoption.html ================================================ ================================================ FILE: src/browser/tests/node/append_child.html ================================================

================================================ FILE: src/browser/tests/node/base_uri.html ================================================ foo ================================================ FILE: src/browser/tests/node/child_nodes.html ================================================

================================================ FILE: src/browser/tests/node/clone_node.html ================================================

Paragraph 1

Paragraph 2

Some text
SVG Text ================================================ FILE: src/browser/tests/node/compare_document_position.html ================================================

Child 1

Grandchild

Child 3

Unrelated
================================================ FILE: src/browser/tests/node/insert_before.html ================================================
================================================ FILE: src/browser/tests/node/is_connected.html ================================================

Connected paragraph

================================================ FILE: src/browser/tests/node/is_equal_node.html ================================================
we're no strangers to love you know the rules and so do I
we're no strangers to love you know the rules and so do I
================================================ FILE: src/browser/tests/node/node.html ================================================

9000!!

================================================ FILE: src/browser/tests/node/node_iterator.html ================================================
Text 1 Text 2
Text 3

Paragraph

================================================ FILE: src/browser/tests/node/normalize.html ================================================
"puppeteer "

Leto Atreides

================================================ FILE: src/browser/tests/node/noscript_serialization.html ================================================ ================================================ FILE: src/browser/tests/node/owner.html ================================================

I am the original reference node.

================================================ FILE: src/browser/tests/node/remove_child.html ================================================

================================================ FILE: src/browser/tests/node/replace_child.html ================================================
================================================ FILE: src/browser/tests/node/text_content.html ================================================
d1

hello

This is a
text
================================================ FILE: src/browser/tests/node/tree.html ================================================

1

2

3

================================================ FILE: src/browser/tests/node/tree_walker.html ================================================
Text 1 Text 2
Text 3

Paragraph

================================================ FILE: src/browser/tests/page/blob.html ================================================ ================================================ FILE: src/browser/tests/page/load_event.html ================================================ ================================================ FILE: src/browser/tests/page/meta.html ================================================ ================================================ FILE: src/browser/tests/page/mod1.js ================================================ const val1 = 'value-1'; export { val1 as "val1" } ================================================ FILE: src/browser/tests/page/module.html ================================================ ================================================ FILE: src/browser/tests/page/modules/base.js ================================================ export const baseValue = 'from-base'; ================================================ FILE: src/browser/tests/page/modules/circular-a.js ================================================ import { getBValue } from './circular-b.js'; export const aValue = 'a'; export function getFromB() { return getBValue(); } ================================================ FILE: src/browser/tests/page/modules/circular-b.js ================================================ import { aValue } from './circular-a.js'; export const bValue = 'b'; export function getBValue() { return bValue; } export function getFromA() { return aValue; } ================================================ FILE: src/browser/tests/page/modules/dynamic-chain-a.js ================================================ export async function loadChain() { const b = await import('./dynamic-chain-b.js'); return b.loadNext(); } export const chainValue = 'chain-a'; ================================================ FILE: src/browser/tests/page/modules/dynamic-chain-b.js ================================================ export async function loadNext() { const c = await import('./dynamic-chain-c.js'); return c.finalValue; } export const chainValue = 'chain-b'; ================================================ FILE: src/browser/tests/page/modules/dynamic-chain-c.js ================================================ export const finalValue = 'chain-end'; ================================================ FILE: src/browser/tests/page/modules/dynamic-circular-x.js ================================================ export const xValue = 'dynamic-x'; export async function loadY() { const y = await import('./dynamic-circular-y.js'); return y.yValue; } ================================================ FILE: src/browser/tests/page/modules/dynamic-circular-y.js ================================================ export const yValue = 'dynamic-y'; export async function loadX() { const x = await import('./dynamic-circular-x.js'); return x.xValue; } ================================================ FILE: src/browser/tests/page/modules/importer.js ================================================ import { baseValue } from './base.js'; export const importedValue = baseValue; export const localValue = 'local'; ================================================ FILE: src/browser/tests/page/modules/mixed-circular-dynamic.js ================================================ import { staticValue } from './mixed-circular-static.js'; export const dynamicValue = 'dynamic-side'; export function getStaticValue() { return staticValue; } ================================================ FILE: src/browser/tests/page/modules/mixed-circular-static.js ================================================ export const staticValue = 'static-side'; export async function loadDynamicSide() { const dynamic = await import('./mixed-circular-dynamic.js'); return dynamic.dynamicValue; } ================================================ FILE: src/browser/tests/page/modules/re-exporter.js ================================================ export { baseValue } from './base.js'; export { importedValue, localValue } from './importer.js'; ================================================ FILE: src/browser/tests/page/modules/self_async.js ================================================ const c = await import('./self_async.js'); ================================================ FILE: src/browser/tests/page/modules/shared.js ================================================ let counter = 0; export function increment() { return ++counter; } export function getCount() { return counter; } ================================================ FILE: src/browser/tests/page/modules/syntax-error.js ================================================ export const value = 'test' this is a syntax error! ================================================ FILE: src/browser/tests/page/modules/test-404.js ================================================ import { something } from './nonexistent.js'; export { something }; ================================================ FILE: src/browser/tests/page/modules/test-syntax-error.js ================================================ import { value } from './syntax-error.js'; export { value }; ================================================ FILE: src/browser/tests/performance.html ================================================ ================================================ FILE: src/browser/tests/performance_observer/performance_observer.html ================================================ ================================================ FILE: src/browser/tests/polyfill/webcomponents.html ================================================
================================================ FILE: src/browser/tests/processing_instruction.html ================================================ ================================================ FILE: src/browser/tests/range.html ================================================

First paragraph

Second paragraph

Span content
================================================ FILE: src/browser/tests/range_mutations.html ================================================ ================================================ FILE: src/browser/tests/selection.html ================================================

The quick brown fox

jumps over the lazy dog

Hello World
================================================ FILE: src/browser/tests/shadowroot/basic.html ================================================
================================================ FILE: src/browser/tests/shadowroot/custom_elements.html ================================================ ================================================ FILE: src/browser/tests/shadowroot/dom_traversal.html ================================================ ================================================ FILE: src/browser/tests/shadowroot/dump.html ================================================ ================================================ FILE: src/browser/tests/shadowroot/edge_cases.html ================================================
================================================ FILE: src/browser/tests/shadowroot/events.html ================================================ ================================================ FILE: src/browser/tests/shadowroot/id_collision.html ================================================
Document
================================================ FILE: src/browser/tests/shadowroot/id_management.html ================================================ ================================================ FILE: src/browser/tests/shadowroot/innerHTML_spec.html ================================================ ================================================ FILE: src/browser/tests/shadowroot/scoping.html ================================================ ================================================ FILE: src/browser/tests/storage.html ================================================ ================================================ FILE: src/browser/tests/streams/readable_stream.html ================================================ ================================================ FILE: src/browser/tests/streams/text_decoder_stream.html ================================================ ================================================ FILE: src/browser/tests/streams/transform_stream.html ================================================ ================================================ FILE: src/browser/tests/support/history.html ================================================ ================================================ FILE: src/browser/tests/testing.js ================================================ (() => { let failed = false; let observed_ids = {}; let eventuallies = []; let async_capture = null; let current_script_id = null; function expectTrue(actual) { expectEqual(true, actual); } function expectFalse(actual) { expectEqual(false, actual); } function expectEqual(expected, actual, opts) { if (_equal(expected, actual)) { _registerObservation('ok', opts); return; } failed = true; _registerObservation('fail', opts); let err = `expected: ${_displayValue(expected)}, got: ${_displayValue(actual)}\n script_id: ${_currentScriptId()}`; if (async_capture) { err += `\n stack: ${async_capture.stack}`; } console.error(err); throw new Error('expectEqual failed'); } function fail(reason) { failed = true; console.error(reason); throw new Error('testing.fail()'); } function expectError(expected, fn) { withError((err) => { expectEqual(true, err.toString().includes(expected)); }, fn); } function withError(cb, fn) { try{ fn(); } catch (err) { cb(err); return; } console.error(`expected error but no error received\n`); throw new Error('no error'); } function eventually(cb) { const script_id = _currentScriptId(); if (!script_id) { throw new Error('testing.eventually called outside of a script'); } eventuallies.push({ callback: cb, script_id: script_id, }); } async function async(cb) { let capture = {script_id: document.currentScript.id, stack: new Error().stack}; await cb(() => { async_capture = capture; }); async_capture = null; } function assertOk() { if (failed) { throw new Error('Failed'); } for (let e of eventuallies) { current_script_id = e.script_id; e.callback(); current_script_id = null; } const script_ids = Object.keys(observed_ids); if (script_ids.length === 0) { throw new Error('no test observations were recorded'); } const scripts = document.getElementsByTagName('script'); for (let script of scripts) { const script_id = script.id; if (!script_id) { continue; } const status = observed_ids[script_id]; if (status !== 'ok') { throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`); } } } const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/"); window.testing = { fail: fail, async: async, assertOk: assertOk, expectTrue: expectTrue, expectFalse: expectFalse, expectEqual: expectEqual, expectError: expectError, withError: withError, eventually: eventually, IS_TEST_RUNNER: IS_TEST_RUNNER, HOST: '127.0.0.1', ORIGIN: 'http://127.0.0.1:9582', BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/', }; if (IS_TEST_RUNNER === false) { // The page is running in a different browser. Probably a developer making sure // a test is correct. There are a few tweaks we need to do to make this a // seemless, namely around adapting paths/urls. console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`); window.testing.HOST = location.hostname; window.testing.ORIGIN = location.origin; window.testing.BASE_URL = location.origin + '/src/browser/tests/'; window.addEventListener('load', testing.assertOk); } window.$ = function(sel) { return document.querySelector(sel); } window.$$ = function(sel) { return document.querySelectorAll(sel); } function _equal(expected, actual) { if (expected === actual) { return true; } if (expected === null || actual === null) { return false; } if (typeof expected !== 'object' || typeof actual !== 'object') { return false; } if (Object.keys(expected).length != Object.keys(actual).length) { return false; } if (expected instanceof Node) { if (!(actual instanceof Node)) { return false; } return expected.isSameNode(actual); } for (property in expected) { if (actual.hasOwnProperty(property) === false) { return false; } if (_equal(expected[property], actual[property]) === false) { return false; } } return true; } function _registerObservation(status, opts) { script_id = opts?.script_id || _currentScriptId(); if (!script_id) { return; } if (observed_ids[script_id] === 'fail') { return; } observed_ids[script_id] = status; if (document.currentScript != null) { if (document.currentScript.onerror === null) { document.currentScript.onerror = function() { observed_ids[document.currentScript.id] = 'fail'; failed = true; } } } } function _currentScriptId() { if (current_script_id) { return current_script_id; } if (async_capture) { return async_capture.script_id; } const current_script = document.currentScript; if (!current_script) { return null; } return current_script.id; } function _displayValue(value) { if (value instanceof Element) { return `HTMLElement: ${value.outerHTML}`; } if (value instanceof Attr) { return `Attribute: ${value.name}: ${value.value}`; } if (value instanceof Node) { return value.nodeName; } if (value === window) { return '#window'; } if (value instanceof Array) { return `array: \n${value.map(_displayValue).join('\n')}\n`; } const seen = []; return JSON.stringify(value, function(key, val) { if (val != null && typeof val == "object") { if (seen.indexOf(val) >= 0) { return; } seen.push(val); } return val; }); } })(); ================================================ FILE: src/browser/tests/url.html ================================================ ================================================ FILE: src/browser/tests/window/body_onload1.html ================================================ ================================================ FILE: src/browser/tests/window/body_onload2.html ================================================ ================================================ FILE: src/browser/tests/window/body_onload3.html ================================================ ================================================ FILE: src/browser/tests/window/location.html ================================================ ================================================ FILE: src/browser/tests/window/named_access.html ================================================

================================================ FILE: src/browser/tests/window/onerror.html ================================================ ================================================ FILE: src/browser/tests/window/report_error.html ================================================ ================================================ FILE: src/browser/tests/window/screen.html ================================================ ================================================ FILE: src/browser/tests/window/scroll.html ================================================ ================================================ FILE: src/browser/tests/window/stubs.html ================================================ ================================================ FILE: src/browser/tests/window/timers.html ================================================ ================================================ FILE: src/browser/tests/window/visual_viewport.html ================================================ ================================================ FILE: src/browser/tests/window/window.html ================================================ ================================================ FILE: src/browser/tests/window/window_event.html ================================================ ================================================ FILE: src/browser/tests/window_scroll.html ================================================ ================================================ FILE: src/browser/tests/xmlserializer.html ================================================ ================================================ FILE: src/browser/webapi/AbortController.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const AbortSignal = @import("AbortSignal.zig"); const AbortController = @This(); _signal: *AbortSignal, pub fn init(page: *Page) !*AbortController { const signal = try AbortSignal.init(page); return page._factory.create(AbortController{ ._signal = signal, }); } pub fn getSignal(self: *const AbortController) *AbortSignal { return self._signal; } pub fn abort(self: *AbortController, reason_: ?js.Value.Global, page: *Page) !void { try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, page); } pub const JsApi = struct { pub const bridge = js.Bridge(AbortController); pub const Meta = struct { pub const name = "AbortController"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const constructor = bridge.constructor(AbortController.init, .{}); pub const signal = bridge.accessor(AbortController.getSignal, null, .{}); pub const abort = bridge.function(AbortController.abort, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: AbortController" { try testing.htmlRunner("event/abort_controller.html", .{}); } ================================================ FILE: src/browser/webapi/AbortSignal.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Event = @import("Event.zig"); const EventTarget = @import("EventTarget.zig"); const AbortSignal = @This(); _proto: *EventTarget, _aborted: bool = false, _reason: Reason = .undefined, _on_abort: ?js.Function.Global = null, pub fn init(page: *Page) !*AbortSignal { return page._factory.eventTarget(AbortSignal{ ._proto = undefined, }); } pub fn getAborted(self: *const AbortSignal) bool { return self._aborted; } pub fn getReason(self: *const AbortSignal) Reason { return self._reason; } pub fn getOnAbort(self: *const AbortSignal) ?js.Function.Global { return self._on_abort; } pub fn setOnAbort(self: *AbortSignal, cb: ?js.Function.Global) !void { self._on_abort = cb; } pub fn asEventTarget(self: *AbortSignal) *EventTarget { return self._proto; } pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void { if (self._aborted) { return; } self._aborted = true; // Store the abort reason (default to a simple string if none provided) if (reason_) |reason| { switch (reason) { .js_val => |js_val| self._reason = .{ .js_val = js_val }, .string => |str| self._reason = .{ .string = try page.dupeString(str) }, .undefined => self._reason = reason, } } else { self._reason = .{ .string = "AbortError" }; } // Dispatch abort event const target = self.asEventTarget(); if (page._event_manager.hasDirectListeners(target, "abort", self._on_abort)) { const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page); try page._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" }); } } // Static method to create an already-aborted signal pub fn createAborted(reason_: ?js.Value.Global, page: *Page) !*AbortSignal { const signal = try init(page); try signal.abort(if (reason_) |r| .{ .js_val = r } else null, page); return signal; } pub fn createTimeout(delay: u32, page: *Page) !*AbortSignal { const callback = try page.arena.create(TimeoutCallback); callback.* = .{ .page = page, .signal = try init(page), }; try page.js.scheduler.add(callback, TimeoutCallback.run, delay, .{ .name = "AbortSignal.timeout", }); return callback.signal; } const ThrowIfAborted = union(enum) { exception: js.Exception, undefined: void, }; pub fn throwIfAborted(self: *const AbortSignal, page: *Page) !ThrowIfAborted { const local = page.js.local.?; if (self._aborted) { const exception = switch (self._reason) { .string => |str| local.throw(str), .js_val => |js_val| local.throw(try local.toLocal(js_val).toStringSlice()), .undefined => local.throw("AbortError"), }; return .{ .exception = exception }; } return .undefined; } const Reason = union(enum) { js_val: js.Value.Global, string: []const u8, undefined: void, }; const TimeoutCallback = struct { page: *Page, signal: *AbortSignal, fn run(ctx: *anyopaque) !?u32 { const self: *TimeoutCallback = @ptrCast(@alignCast(ctx)); self.signal.abort(.{ .string = "TimeoutError" }, self.page) catch |err| { log.warn(.app, "abort signal timeout", .{ .err = err }); }; return null; } }; pub const JsApi = struct { pub const bridge = js.Bridge(AbortSignal); pub const Meta = struct { pub const name = "AbortSignal"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const Prototype = EventTarget; pub const constructor = bridge.constructor(AbortSignal.init, .{}); pub const aborted = bridge.accessor(AbortSignal.getAborted, null, .{}); pub const reason = bridge.accessor(AbortSignal.getReason, null, .{}); pub const onabort = bridge.accessor(AbortSignal.getOnAbort, AbortSignal.setOnAbort, .{}); pub const throwIfAborted = bridge.function(AbortSignal.throwIfAborted, .{}); // Static method pub const abort = bridge.function(AbortSignal.createAborted, .{ .static = true }); pub const timeout = bridge.function(AbortSignal.createTimeout, .{ .static = true }); }; ================================================ FILE: src/browser/webapi/AbstractRange.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const Range = @import("Range.zig"); const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; const AbstractRange = @This(); pub const _prototype_root = true; _rc: u8, _type: Type, _page_id: u32, _arena: Allocator, _end_offset: u32, _start_offset: u32, _end_container: *Node, _start_container: *Node, // Intrusive linked list node for tracking live ranges on the Page. _range_link: std.DoublyLinkedList.Node = .{}, pub fn acquireRef(self: *AbstractRange) void { self._rc += 1; } pub fn deinit(self: *AbstractRange, shutdown: bool, session: *Session) void { _ = shutdown; const rc = self._rc; if (comptime IS_DEBUG) { std.debug.assert(rc != 0); } if (rc == 1) { if (session.findPageById(self._page_id)) |page| { page._live_ranges.remove(&self._range_link); } session.releaseArena(self._arena); return; } self._rc = rc - 1; } pub const Type = union(enum) { range: *Range, // TODO: static_range: *StaticRange, }; pub fn as(self: *AbstractRange, comptime T: type) *T { return self.is(T).?; } pub fn is(self: *AbstractRange, comptime T: type) ?*T { switch (self._type) { .range => |r| return if (T == Range) r else null, } } pub fn getStartContainer(self: *const AbstractRange) *Node { return self._start_container; } pub fn getStartOffset(self: *const AbstractRange) u32 { return self._start_offset; } pub fn getEndContainer(self: *const AbstractRange) *Node { return self._end_container; } pub fn getEndOffset(self: *const AbstractRange) u32 { return self._end_offset; } pub fn getCollapsed(self: *const AbstractRange) bool { return self._start_container == self._end_container and self._start_offset == self._end_offset; } pub fn getCommonAncestorContainer(self: *const AbstractRange) *Node { // Let container be start container var container = self._start_container; // While container is not an inclusive ancestor of end container while (!isInclusiveAncestorOf(container, self._end_container)) { // Let container be container's parent container = container.parentNode() orelse break; } return container; } pub fn isStartAfterEnd(self: *const AbstractRange) bool { return compareBoundaryPoints( self._start_container, self._start_offset, self._end_container, self._end_offset, ) == .after; } const BoundaryComparison = enum { before, equal, after, }; pub fn compareBoundaryPoints( node_a: *Node, offset_a: u32, node_b: *Node, offset_b: u32, ) BoundaryComparison { // If same container, just compare offsets if (node_a == node_b) { if (offset_a < offset_b) return .before; if (offset_a > offset_b) return .after; return .equal; } // Check if one contains the other if (isAncestorOf(node_a, node_b)) { // A contains B, so A's position comes before B // But we need to check if the offset in A comes after B var child = node_b; var parent = child.parentNode(); while (parent) |p| { if (p == node_a) { const child_index = p.getChildIndex(child) orelse unreachable; if (offset_a <= child_index) { return .before; } return .after; } child = p; parent = p.parentNode(); } unreachable; } if (isAncestorOf(node_b, node_a)) { // B contains A, so B's position comes before A var child = node_a; var parent = child.parentNode(); while (parent) |p| { if (p == node_b) { const child_index = p.getChildIndex(child) orelse unreachable; if (child_index < offset_b) { return .before; } return .after; } child = p; parent = p.parentNode(); } unreachable; } // Neither contains the other, find their relative position in tree order // Walk up from A to find all ancestors var current = node_a; var a_count: usize = 0; var a_ancestors: [64]*Node = undefined; while (a_count < 64) { a_ancestors[a_count] = current; a_count += 1; current = current.parentNode() orelse break; } // Walk up from B and find first common ancestor current = node_b; while (current.parentNode()) |parent| { for (a_ancestors[0..a_count]) |ancestor| { if (ancestor != parent) { continue; } // Found common ancestor // Now compare positions of the children in this ancestor const a_child = blk: { var node = node_a; while (node.parentNode()) |p| { if (p == parent) break :blk node; node = p; } unreachable; }; const b_child = current; const a_index = parent.getChildIndex(a_child) orelse unreachable; const b_index = parent.getChildIndex(b_child) orelse unreachable; if (a_index < b_index) { return .before; } if (a_index > b_index) { return .after; } return .equal; } current = parent; } // Should not reach here if nodes are in the same tree return .before; } fn isAncestorOf(potential_ancestor: *Node, node: *Node) bool { var current = node.parentNode(); while (current) |parent| { if (parent == potential_ancestor) { return true; } current = parent.parentNode(); } return false; } fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool { if (potential_ancestor == node) { return true; } return isAncestorOf(potential_ancestor, node); } /// Update this range's boundaries after a replaceData mutation on target. /// All parameters are in UTF-16 code unit offsets. pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void { if (self._start_container == target) { if (self._start_offset > offset and self._start_offset <= offset + count) { self._start_offset = offset; } else if (self._start_offset > offset + count) { // Use i64 intermediate to avoid u32 underflow when count > data_len self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count)); } } if (self._end_container == target) { if (self._end_offset > offset and self._end_offset <= offset + count) { self._end_offset = offset; } else if (self._end_offset > offset + count) { self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count)); } } } /// Update this range's boundaries after a splitText operation. /// Steps 7b-7e of the DOM spec splitText algorithm. pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void { // Step 7b: ranges on the original node with start > offset move to new node if (self._start_container == target and self._start_offset > offset) { self._start_container = new_node; self._start_offset = self._start_offset - offset; } // Step 7c: ranges on the original node with end > offset move to new node if (self._end_container == target and self._end_offset > offset) { self._end_container = new_node; self._end_offset = self._end_offset - offset; } // Step 7d: ranges on parent with start == node_index + 1 increment if (self._start_container == parent and self._start_offset == node_index + 1) { self._start_offset += 1; } // Step 7e: ranges on parent with end == node_index + 1 increment if (self._end_container == parent and self._end_offset == node_index + 1) { self._end_offset += 1; } } /// Update this range's boundaries after a node insertion. pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void { if (self._start_container == parent and self._start_offset > child_index) { self._start_offset += 1; } if (self._end_container == parent and self._end_offset > child_index) { self._end_offset += 1; } } /// Update this range's boundaries after a node removal. pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void { // Steps 4-5: ranges whose start/end is an inclusive descendant of child // get moved to (parent, child_index). if (isInclusiveDescendantOf(self._start_container, child)) { self._start_container = parent; self._start_offset = child_index; } if (isInclusiveDescendantOf(self._end_container, child)) { self._end_container = parent; self._end_offset = child_index; } // Steps 6-7: ranges on parent at offsets > child_index get decremented. if (self._start_container == parent and self._start_offset > child_index) { self._start_offset -= 1; } if (self._end_container == parent and self._end_offset > child_index) { self._end_offset -= 1; } } fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool { var current: ?*Node = node; while (current) |n| { if (n == potential_ancestor) return true; current = n.parentNode(); } return false; } pub const JsApi = struct { pub const bridge = js.Bridge(AbstractRange); pub const Meta = struct { pub const name = "AbstractRange"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(AbstractRange.deinit); }; pub const startContainer = bridge.accessor(AbstractRange.getStartContainer, null, .{}); pub const startOffset = bridge.accessor(AbstractRange.getStartOffset, null, .{}); pub const endContainer = bridge.accessor(AbstractRange.getEndContainer, null, .{}); pub const endOffset = bridge.accessor(AbstractRange.getEndOffset, null, .{}); pub const collapsed = bridge.accessor(AbstractRange.getCollapsed, null, .{}); pub const commonAncestorContainer = bridge.accessor(AbstractRange.getCommonAncestorContainer, null, .{}); }; ================================================ FILE: src/browser/webapi/Blob.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Writer = std.Io.Writer; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Mime = @import("../Mime.zig"); const Allocator = std.mem.Allocator; /// https://w3c.github.io/FileAPI/#blob-section /// https://developer.mozilla.org/en-US/docs/Web/API/Blob const Blob = @This(); pub const _prototype_root = true; _type: Type, _arena: Allocator, /// Immutable slice of blob. /// Note that another blob may hold a pointer/slice to this, /// so its better to leave the deallocation of it to arena allocator. _slice: []const u8, /// MIME attached to blob. Can be an empty string. _mime: []const u8, pub const Type = union(enum) { generic, file: *@import("File.zig"), }; const InitOptions = struct { /// MIME type. type: []const u8 = "", /// How to handle line endings (CR and LF). /// `transparent` means do nothing, `native` expects CRLF (\r\n) on Windows. endings: []const u8 = "transparent", }; /// Creates a new Blob (JS constructor). pub fn init( maybe_blob_parts: ?[]const []const u8, maybe_options: ?InitOptions, page: *Page, ) !*Blob { return initWithMimeValidation(maybe_blob_parts, maybe_options, false, page); } /// Creates a new Blob with optional MIME validation. /// When validate_mime is true, uses full MIME parsing (for Response/Request). /// When false, uses simple ASCII validation per FileAPI spec (for Blob constructor). pub fn initWithMimeValidation( maybe_blob_parts: ?[]const []const u8, maybe_options: ?InitOptions, validate_mime: bool, page: *Page, ) !*Blob { const arena = try page.getArena(.{ .debug = "Blob" }); errdefer page.releaseArena(arena); const options: InitOptions = maybe_options orelse .{}; const mime: []const u8 = blk: { const t = options.type; if (t.len == 0) { break :blk ""; } const buf = try arena.dupe(u8, t); if (validate_mime) { // Full MIME parsing per MIME sniff spec (for Content-Type headers) _ = Mime.parse(buf) catch break :blk ""; } else { // Simple validation per FileAPI spec (for Blob constructor): // - If any char is outside U+0020-U+007E, return empty string // - Otherwise lowercase for (t) |c| { if (c < 0x20 or c > 0x7E) { break :blk ""; } } _ = std.ascii.lowerString(buf, buf); } break :blk buf; }; const data = blk: { if (maybe_blob_parts) |blob_parts| { var w: Writer.Allocating = .init(arena); const use_native_endings = std.mem.eql(u8, options.endings, "native"); try writeBlobParts(&w.writer, blob_parts, use_native_endings); break :blk w.written(); } break :blk ""; }; const self = try arena.create(Blob); self.* = .{ ._arena = arena, ._type = .generic, ._slice = data, ._mime = mime, }; return self; } pub fn deinit(self: *Blob, shutdown: bool, session: *Session) void { _ = shutdown; session.releaseArena(self._arena); } const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8); /// Array of possible vector sizes for the current arch in decrementing order. /// We may move this to some file for SIMD helpers in the future. const vector_sizes = blk: { // Required for length calculation. var n: usize = largest_vector; var total: usize = 0; while (n != 2) : (n /= 2) total += 1; // Populate an array with vector sizes. n = largest_vector; var i: usize = 0; var items: [total]usize = undefined; while (n != 2) : (n /= 2) { defer i += 1; items[i] = n; } break :blk items; }; /// Writes blob parts to given `Writer` with desired endings. fn writeBlobParts( writer: *Writer, blob_parts: []const []const u8, use_native_endings: bool, ) !void { // Transparent. if (!use_native_endings) { for (blob_parts) |part| { try writer.writeAll(part); } return; } // TODO: Windows support. // Linux & Unix. // Both Firefox and Chrome implement it as such: // CRLF => LF // CR => LF // So even though CR is not followed by LF, it gets replaced. // // I believe this is because such scenario is possible: // ``` // let parts = [ "the quick\r", "\nbrown fox" ]; // ``` // In the example, one should have to check the part before in order to // understand that CRLF is being presented in the final buffer. // So they took a simpler approach, here's what given blob parts produce: // ``` // "the quick\n\nbrown fox" // ``` scan_parts: for (blob_parts) |part| { var end: usize = 0; inline for (vector_sizes) |vector_len| { const Vec = @Vector(vector_len, u8); while (end + vector_len <= part.len) : (end += vector_len) { const cr: Vec = @splat('\r'); // Load chunk as vectors. const data = part[end..][0..vector_len]; const chunk: Vec = data.*; // Look for CR. const match = chunk == cr; // Create a bitset out of match vector. const bitset = std.bit_set.IntegerBitSet(vector_len){ .mask = @bitCast(@intFromBool(match)), }; var iter = bitset.iterator(.{}); var relative_start: usize = 0; while (iter.next()) |index| { _ = try writer.writeVec(&.{ data[relative_start..index], "\n" }); if (index + 1 != data.len and data[index + 1] == '\n') { relative_start = index + 2; } else { relative_start = index + 1; } } _ = try writer.writeVec(&.{data[relative_start..]}); } } // Scalar scan fallback. var relative_start: usize = end; while (end < part.len) { if (part[end] == '\r') { _ = try writer.writeVec(&.{ part[relative_start..end], "\n" }); // Part ends with CR. We can continue to next part. if (end + 1 == part.len) { continue :scan_parts; } // If next char is LF, skip it too. if (part[end + 1] == '\n') { relative_start = end + 2; } else { relative_start = end + 1; } } end += 1; } // Write the remaining. We get this in such situations: // `the quick brown\rfox` // `the quick brown\r\nfox` try writer.writeAll(part[relative_start..end]); } } /// Returns a Promise that resolves with the contents of the blob /// as binary data contained in an ArrayBuffer. pub fn arrayBuffer(self: *const Blob, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._slice }); } const ReadableStream = @import("streams/ReadableStream.zig"); /// Returns a ReadableStream which upon reading returns the data /// contained within the Blob. pub fn stream(self: *const Blob, page: *Page) !*ReadableStream { return ReadableStream.initWithData(self._slice, page); } /// Returns a Promise that resolves with a string containing /// the contents of the blob, interpreted as UTF-8. pub fn text(self: *const Blob, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(self._slice); } /// Extension to Blob; works on Firefox and Safari. /// https://developer.mozilla.org/en-US/docs/Web/API/Blob/bytes /// Returns a Promise that resolves with a Uint8Array containing /// the contents of the blob as an array of bytes. pub fn bytes(self: *const Blob, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(js.TypedArray(u8){ .values = self._slice }); } /// Returns a new Blob object which contains data /// from a subset of the blob on which it's called. pub fn slice( self: *const Blob, start_: ?i32, end_: ?i32, content_type_: ?[]const u8, page: *Page, ) !*Blob { const data = self._slice; const start = blk: { const requested_start = start_ orelse break :blk 0; if (requested_start < 0) { break :blk data.len -| @abs(requested_start); } break :blk @min(data.len, @as(u31, @intCast(requested_start))); }; const end: usize = blk: { const requested_end = end_ orelse break :blk data.len; if (requested_end < 0) { break :blk @max(start, data.len -| @abs(requested_end)); } break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end)))); }; return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page); } /// Returns the size of the Blob in bytes. pub fn getSize(self: *const Blob) usize { return self._slice.len; } /// Returns the type of Blob; likely a MIME type, yet anything can be given. pub fn getType(self: *const Blob) []const u8 { return self._mime; } pub const JsApi = struct { pub const bridge = js.Bridge(Blob); pub const Meta = struct { pub const name = "Blob"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(Blob.deinit); }; pub const constructor = bridge.constructor(Blob.init, .{}); pub const text = bridge.function(Blob.text, .{}); pub const bytes = bridge.function(Blob.bytes, .{}); pub const slice = bridge.function(Blob.slice, .{}); pub const size = bridge.accessor(Blob.getSize, null, .{}); pub const @"type" = bridge.accessor(Blob.getType, null, .{}); pub const stream = bridge.function(Blob.stream, .{}); pub const arrayBuffer = bridge.function(Blob.arrayBuffer, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: Blob" { try testing.htmlRunner("blob.html", .{}); } ================================================ FILE: src/browser/webapi/CData.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); pub const Text = @import("cdata/Text.zig"); pub const Comment = @import("cdata/Comment.zig"); pub const CDATASection = @import("cdata/CDATASection.zig"); pub const ProcessingInstruction = @import("cdata/ProcessingInstruction.zig"); const CData = @This(); _type: Type, _proto: *Node, _data: String = .empty, /// Count UTF-16 code units in a UTF-8 string. /// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair), /// everything else produces 1. pub fn utf16Len(data: []const u8) usize { var count: usize = 0; var i: usize = 0; while (i < data.len) { const byte = data[i]; const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch { // Invalid UTF-8 byte — count as 1 code unit, advance 1 byte i += 1; count += 1; continue; }; if (i + seq_len > data.len) { // Truncated sequence count += 1; i += 1; continue; } if (seq_len == 4) { count += 2; // surrogate pair } else { count += 1; } i += seq_len; } return count; } /// Convert a UTF-16 code unit offset to a UTF-8 byte offset. /// Returns IndexSizeError if utf16_offset > utf16 length of data. pub fn utf16OffsetToUtf8(data: []const u8, utf16_offset: usize) error{IndexSizeError}!usize { var utf16_pos: usize = 0; var i: usize = 0; while (i < data.len) { if (utf16_pos == utf16_offset) return i; const byte = data[i]; const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch { i += 1; utf16_pos += 1; continue; }; if (i + seq_len > data.len) { utf16_pos += 1; i += 1; continue; } if (seq_len == 4) { utf16_pos += 2; } else { utf16_pos += 1; } i += seq_len; } // At end of string — valid only if offset equals total length if (utf16_pos == utf16_offset) return i; return error.IndexSizeError; } /// Convert a UTF-16 code unit range to UTF-8 byte offsets in a single pass. /// Returns IndexSizeError if utf16_start > utf16 length of data. /// Clamps utf16_end to the actual string length if it exceeds it. fn utf16RangeToUtf8(data: []const u8, utf16_start: usize, utf16_end: usize) !struct { start: usize, end: usize } { var i: usize = 0; var utf16_pos: usize = 0; var byte_start: ?usize = null; while (i < data.len) { // Record start offset when we reach it if (utf16_pos == utf16_start) { byte_start = i; } // If we've found start and reached end, return both if (utf16_pos == utf16_end and byte_start != null) { return .{ .start = byte_start.?, .end = i }; } const byte = data[i]; const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch { i += 1; utf16_pos += 1; continue; }; if (i + seq_len > data.len) { utf16_pos += 1; i += 1; continue; } utf16_pos += if (seq_len == 4) 2 else 1; i += seq_len; } // At end of string if (utf16_pos == utf16_start) { byte_start = i; } const start = byte_start orelse return error.IndexSizeError; // End is either exactly at utf16_end or clamped to string end return .{ .start = start, .end = i }; } pub const Type = union(enum) { text: Text, comment: Comment, // This should be under Text, but that would require storing a _type union // in text, which would add 8 bytes to every text node. cdata_section: CDATASection, processing_instruction: *ProcessingInstruction, }; pub fn asNode(self: *CData) *Node { return self._proto; } pub fn is(self: *CData, comptime T: type) ?*T { inline for (@typeInfo(Type).@"union".fields) |f| { if (@field(Type, f.name) == self._type) { if (f.type == T) { return &@field(self._type, f.name); } if (f.type == *T) { return @field(self._type, f.name); } } } return null; } pub fn getData(self: *const CData) String { return self._data; } pub const RenderOpts = struct { trim_left: bool = true, trim_right: bool = true, }; // Replace successives whitespaces with one withespace. // Trims left and right according to the options. // Returns true if the string ends with a trimmed whitespace. pub fn render(self: *const CData, writer: *std.io.Writer, opts: RenderOpts) !bool { var start: usize = 0; var prev_w: ?bool = null; var is_w: bool = undefined; const s = self._data.str(); for (s, 0..) |c, i| { is_w = std.ascii.isWhitespace(c); // Detect the first char type. if (prev_w == null) { prev_w = is_w; } // The current char is the same kind of char, the chunk continues. if (prev_w.? == is_w) { continue; } // Starting here, the chunk changed. if (is_w) { // We have a chunk of non-whitespaces, we write it as it. try writer.writeAll(s[start..i]); } else { // We have a chunk of whitespaces, replace with one space, // depending the position. if (start > 0 or !opts.trim_left) { try writer.writeByte(' '); } } // Start the new chunk. prev_w = is_w; start = i; } // Write the reminder chunk. if (is_w) { // Last chunk is whitespaces. // If the string contains only whitespaces, don't write it. if (start > 0 and opts.trim_right == false) { try writer.writeByte(' '); } else { return true; } } else { // last chunk is non whitespaces. try writer.writeAll(s[start..]); } return false; } pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { const old_value = self._data; if (value) |v| { self._data = try page.dupeSSO(v); } else { self._data = .empty; } page.characterDataChange(self.asNode(), old_value); } /// JS bridge wrapper for `data` setter. /// Per spec, setting .data runs replaceData(0, this.length, value), /// which includes live range updates. /// Handles [LegacyNullToEmptyString]: null → "" per spec. pub fn _setData(self: *CData, value: js.Value, page: *Page) !void { const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8); const length = self.getLength(); try self.replaceData(0, length, new_value, page); } pub fn format(self: *const CData, writer: *std.io.Writer) !void { return switch (self._type) { .text => writer.print("{f}", .{self._data}), .comment => writer.print("", .{self._data}), .cdata_section => writer.print("", .{self._data}), .processing_instruction => |pi| writer.print("", .{ pi._target, self._data }), }; } pub fn getLength(self: *const CData) usize { return utf16Len(self._data.str()); } pub fn isEqualNode(self: *const CData, other: *const CData) bool { if (std.meta.activeTag(self._type) != std.meta.activeTag(other._type)) { return false; } if (self._type == .processing_instruction) { @branchHint(.unlikely); if (std.mem.eql(u8, self._type.processing_instruction._target, other._type.processing_instruction._target) == false) { return false; } // if the _targets are equal, we still want to compare the data } return self._data.eql(other._data); } pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { // Per DOM spec, appendData(data) is replaceData(length, 0, data). const length = self.getLength(); try self.replaceData(length, 0, data, page); } pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); // Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="") const length = self.getLength(); const effective_count: u32 = @intCast(@min(count, length - offset)); page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0); const old_data = self._data; const old_value = old_data.str(); if (range.start == 0) { self._data = try page.dupeSSO(old_value[range.end..]); } else if (range.end >= old_value.len) { self._data = try page.dupeSSO(old_value[0..range.start]); } else { // Deleting from middle - concat prefix and suffix self._data = try String.concat(page.arena, &.{ old_value[0..range.start], old_value[range.end..], }); } page.characterDataChange(self.asNode(), old_data); } pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void { const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset); // Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0) page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data))); const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ existing[0..byte_offset], data, existing[byte_offset..], }); page.characterDataChange(self.asNode(), old_value); } pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); // Update live ranges per DOM spec replaceData steps const length = self.getLength(); const effective_count: u32 = @intCast(@min(count, length - offset)); page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data))); const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ existing[0..range.start], data, existing[range.end..], }); page.characterDataChange(self.asNode(), old_value); } pub fn substringData(self: *const CData, offset: usize, count: usize) ![]const u8 { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); return self._data.str()[range.start..range.end]; } pub fn remove(self: *CData, page: *Page) !void { const node = self.asNode(); const parent = node.parentNode() orelse return; _ = try parent.removeChild(node, page); } pub fn before(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void { const node = self.asNode(); const parent = node.parentNode() orelse return; for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); _ = try parent.insertBefore(child, node, page); } } pub fn after(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void { const node = self.asNode(); const parent = node.parentNode() orelse return; const viable_next = Node.NodeOrText.viableNextSibling(node, nodes); for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); _ = try parent.insertBefore(child, viable_next, page); } } pub fn replaceWith(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void { const ref_node = self.asNode(); const parent = ref_node.parentNode() orelse return; var rm_ref_node = true; for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); if (child == ref_node) { rm_ref_node = false; continue; } _ = try parent.insertBefore(child, ref_node, page); } if (rm_ref_node) { _ = try parent.removeChild(ref_node, page); } } pub fn nextElementSibling(self: *CData) ?*Node.Element { var maybe_sibling = self.asNode().nextSibling(); while (maybe_sibling) |sibling| { if (sibling.is(Node.Element)) |el| return el; maybe_sibling = sibling.nextSibling(); } return null; } pub fn previousElementSibling(self: *CData) ?*Node.Element { var maybe_sibling = self.asNode().previousSibling(); while (maybe_sibling) |sibling| { if (sibling.is(Node.Element)) |el| return el; maybe_sibling = sibling.previousSibling(); } return null; } pub const JsApi = struct { pub const bridge = js.Bridge(CData); pub const Meta = struct { pub const name = "CharacterData"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const data = bridge.accessor(CData.getData, CData._setData, .{}); pub const length = bridge.accessor(CData.getLength, null, .{}); pub const appendData = bridge.function(CData.appendData, .{}); pub const deleteData = bridge.function(CData.deleteData, .{ .dom_exception = true }); pub const insertData = bridge.function(CData.insertData, .{ .dom_exception = true }); pub const replaceData = bridge.function(CData.replaceData, .{ .dom_exception = true }); pub const substringData = bridge.function(CData.substringData, .{ .dom_exception = true }); pub const remove = bridge.function(CData.remove, .{}); pub const before = bridge.function(CData.before, .{}); pub const after = bridge.function(CData.after, .{}); pub const replaceWith = bridge.function(CData.replaceWith, .{}); pub const nextElementSibling = bridge.accessor(CData.nextElementSibling, null, .{}); pub const previousElementSibling = bridge.accessor(CData.previousElementSibling, null, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: CData" { try testing.htmlRunner("cdata", .{}); } test "WebApi: CData.render" { const allocator = std.testing.allocator; const TestCase = struct { value: []const u8, expected: []const u8, result: bool = false, opts: RenderOpts = .{}, }; const test_cases = [_]TestCase{ .{ .value = " ", .expected = "", .result = true }, .{ .value = " ", .expected = "", .opts = .{ .trim_left = false, .trim_right = false }, .result = true }, .{ .value = "foo bar", .expected = "foo bar" }, .{ .value = "foo bar", .expected = "foo bar" }, .{ .value = " foo bar", .expected = "foo bar" }, .{ .value = "foo bar ", .expected = "foo bar", .result = true }, .{ .value = " foo bar ", .expected = "foo bar", .result = true }, .{ .value = "foo\n\tbar", .expected = "foo bar" }, .{ .value = "\tfoo bar baz \t\n yeah\r\n", .expected = "foo bar baz yeah", .result = true }, .{ .value = " foo bar", .expected = " foo bar", .opts = .{ .trim_left = false } }, .{ .value = "foo bar ", .expected = "foo bar ", .opts = .{ .trim_right = false } }, .{ .value = " foo bar ", .expected = " foo bar ", .opts = .{ .trim_left = false, .trim_right = false } }, }; var buffer = std.io.Writer.Allocating.init(allocator); defer buffer.deinit(); for (test_cases) |test_case| { buffer.clearRetainingCapacity(); const cdata = CData{ ._type = .{ .text = undefined }, ._proto = undefined, ._data = .wrap(test_case.value), }; const result = try cdata.render(&buffer.writer, test_case.opts); try std.testing.expectEqualStrings(test_case.expected, buffer.written()); try std.testing.expect(result == test_case.result); } } test "utf16Len" { // ASCII: 1 byte = 1 code unit each try std.testing.expectEqual(@as(usize, 0), utf16Len("")); try std.testing.expectEqual(@as(usize, 5), utf16Len("hello")); // CJK: 3 bytes UTF-8 = 1 UTF-16 code unit each try std.testing.expectEqual(@as(usize, 2), utf16Len("資料")); // 6 bytes, 2 code units // Emoji U+1F320: 4 bytes UTF-8 = 2 UTF-16 code units (surrogate pair) try std.testing.expectEqual(@as(usize, 2), utf16Len("🌠")); // 4 bytes, 2 code units // Mixed: 🌠(2) + " test "(6) + 🌠(2) + " TEST"(5) = 15 try std.testing.expectEqual(@as(usize, 15), utf16Len("🌠 test 🌠 TEST")); // 2-byte UTF-8 (e.g. é U+00E9): 1 UTF-16 code unit try std.testing.expectEqual(@as(usize, 4), utf16Len("café")); // c(1) + a(1) + f(1) + é(1) } test "utf16OffsetToUtf8" { // ASCII: offsets map 1:1 try std.testing.expectEqual(@as(usize, 0), try utf16OffsetToUtf8("hello", 0)); try std.testing.expectEqual(@as(usize, 3), try utf16OffsetToUtf8("hello", 3)); try std.testing.expectEqual(@as(usize, 5), try utf16OffsetToUtf8("hello", 5)); // end try std.testing.expectError(error.IndexSizeError, utf16OffsetToUtf8("hello", 6)); // past end // CJK "資料" (6 bytes, 2 UTF-16 code units) try std.testing.expectEqual(@as(usize, 0), try utf16OffsetToUtf8("資料", 0)); // before 資 try std.testing.expectEqual(@as(usize, 3), try utf16OffsetToUtf8("資料", 1)); // before 料 try std.testing.expectEqual(@as(usize, 6), try utf16OffsetToUtf8("資料", 2)); // end try std.testing.expectError(error.IndexSizeError, utf16OffsetToUtf8("資料", 3)); // Emoji "🌠AB" (4+1+1 = 6 bytes; 2+1+1 = 4 UTF-16 code units) try std.testing.expectEqual(@as(usize, 0), try utf16OffsetToUtf8("🌠AB", 0)); // before 🌠 // offset 1 lands inside the surrogate pair — still valid UTF-16 offset try std.testing.expectEqual(@as(usize, 4), try utf16OffsetToUtf8("🌠AB", 2)); // before A try std.testing.expectEqual(@as(usize, 5), try utf16OffsetToUtf8("🌠AB", 3)); // before B try std.testing.expectEqual(@as(usize, 6), try utf16OffsetToUtf8("🌠AB", 4)); // end // Empty string: only offset 0 is valid try std.testing.expectEqual(@as(usize, 0), try utf16OffsetToUtf8("", 0)); try std.testing.expectError(error.IndexSizeError, utf16OffsetToUtf8("", 1)); } test "utf16RangeToUtf8" { // ASCII: basic range { const result = try utf16RangeToUtf8("hello", 1, 4); try std.testing.expectEqual(@as(usize, 1), result.start); try std.testing.expectEqual(@as(usize, 4), result.end); } // ASCII: range to end { const result = try utf16RangeToUtf8("hello", 2, 5); try std.testing.expectEqual(@as(usize, 2), result.start); try std.testing.expectEqual(@as(usize, 5), result.end); } // ASCII: range past end (should clamp) { const result = try utf16RangeToUtf8("hello", 2, 100); try std.testing.expectEqual(@as(usize, 2), result.start); try std.testing.expectEqual(@as(usize, 5), result.end); // clamped } // ASCII: full range { const result = try utf16RangeToUtf8("hello", 0, 5); try std.testing.expectEqual(@as(usize, 0), result.start); try std.testing.expectEqual(@as(usize, 5), result.end); } // ASCII: start past end try std.testing.expectError(error.IndexSizeError, utf16RangeToUtf8("hello", 6, 10)); // CJK "資料" (6 bytes, 2 UTF-16 code units) { const result = try utf16RangeToUtf8("資料", 0, 1); try std.testing.expectEqual(@as(usize, 0), result.start); try std.testing.expectEqual(@as(usize, 3), result.end); // after 資 } { const result = try utf16RangeToUtf8("資料", 1, 2); try std.testing.expectEqual(@as(usize, 3), result.start); // before 料 try std.testing.expectEqual(@as(usize, 6), result.end); // end } { const result = try utf16RangeToUtf8("資料", 0, 2); try std.testing.expectEqual(@as(usize, 0), result.start); try std.testing.expectEqual(@as(usize, 6), result.end); } // Emoji "🌠AB" (4+1+1 = 6 bytes; 2+1+1 = 4 UTF-16 code units) { const result = try utf16RangeToUtf8("🌠AB", 0, 2); try std.testing.expectEqual(@as(usize, 0), result.start); try std.testing.expectEqual(@as(usize, 4), result.end); // after 🌠 } { const result = try utf16RangeToUtf8("🌠AB", 2, 3); try std.testing.expectEqual(@as(usize, 4), result.start); // before A try std.testing.expectEqual(@as(usize, 5), result.end); // before B } { const result = try utf16RangeToUtf8("🌠AB", 0, 4); try std.testing.expectEqual(@as(usize, 0), result.start); try std.testing.expectEqual(@as(usize, 6), result.end); } // Empty string { const result = try utf16RangeToUtf8("", 0, 0); try std.testing.expectEqual(@as(usize, 0), result.start); try std.testing.expectEqual(@as(usize, 0), result.end); } { const result = try utf16RangeToUtf8("", 0, 100); try std.testing.expectEqual(@as(usize, 0), result.start); try std.testing.expectEqual(@as(usize, 0), result.end); // clamped } // Mixed "🌠 test 🌠" (4+1+4+1+4 = 14 bytes; 2+1+4+1+2 = 10 UTF-16 code units) { const result = try utf16RangeToUtf8("🌠 test 🌠", 3, 7); try std.testing.expectEqual(@as(usize, 5), result.start); // before 'test' try std.testing.expectEqual(@as(usize, 9), result.end); // after 'test', before second space } } ================================================ FILE: src/browser/webapi/CSS.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const CSS = @This(); _pad: bool = false, pub const init: CSS = .{}; pub fn parseDimension(value: []const u8) ?f64 { if (value.len == 0) { return null; } var num_str = value; if (std.mem.endsWith(u8, value, "px")) { num_str = value[0 .. value.len - 2]; } return std.fmt.parseFloat(f64, num_str) catch null; } /// Escapes a CSS identifier string /// https://drafts.csswg.org/cssom/#the-css.escape()-method pub fn escape(_: *const CSS, value: []const u8, page: *Page) ![]const u8 { if (value.len == 0) { return ""; } const first = value[0]; if (first == '-' and value.len == 1) { return "\\-"; } // Count how many characters we need for the output var out_len: usize = escapeLen(true, first); for (value[1..], 0..) |c, i| { // Second char (i==0) is a digit and first is '-', needs hex escape if (i == 0 and first == '-' and c >= '0' and c <= '9') { out_len += 2 + hexDigitsNeeded(c); } else { out_len += escapeLen(false, c); } } if (out_len == value.len) { return value; } const result = try page.call_arena.alloc(u8, out_len); var pos: usize = 0; if (needsEscape(true, first)) { pos = writeEscape(true, result, first); } else { result[0] = first; pos = 1; } for (value[1..], 0..) |c, i| { // Second char (i==0) is a digit and first is '-', needs hex escape if (i == 0 and first == '-' and c >= '0' and c <= '9') { result[pos] = '\\'; const hex_str = std.fmt.bufPrint(result[pos + 1 ..], "{x} ", .{c}) catch unreachable; pos += 1 + hex_str.len; } else if (!needsEscape(false, c)) { result[pos] = c; pos += 1; } else { pos += writeEscape(false, result[pos..], c); } } return result; } pub fn supports(_: *const CSS, property_or_condition: []const u8, value: ?[]const u8) bool { _ = property_or_condition; _ = value; return true; } fn escapeLen(comptime is_first: bool, c: u8) usize { if (needsEscape(is_first, c) == false) { return 1; } if (c == 0) { return "\u{FFFD}".len; } if (isHexEscape(c) or ((comptime is_first) and c >= '0' and c <= '9')) { // Will be escaped as \XX (backslash + 1-6 hex digits + space) return 2 + hexDigitsNeeded(c); } // Escaped as \C (backslash + character) return 2; } fn needsEscape(comptime is_first: bool, c: u8) bool { if (comptime is_first) { if (c >= '0' and c <= '9') { return true; } } // Characters that need escaping return switch (c) { 0...0x1F, 0x7F => true, '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '`', '{', '|', '}', '~' => true, ' ' => true, else => false, }; } fn isHexEscape(c: u8) bool { return (c >= 0x00 and c <= 0x1F) or c == 0x7F; } fn hexDigitsNeeded(c: u8) usize { if (c < 0x10) { return 1; } return 2; } fn writeEscape(comptime is_first: bool, buf: []u8, c: u8) usize { if (c == 0) { // NULL character becomes replacement character (no backslash) const replacement = "\u{FFFD}"; @memcpy(buf[0..replacement.len], replacement); return replacement.len; } buf[0] = '\\'; var data = buf[1..]; if (isHexEscape(c) or ((comptime is_first) and c >= '0' and c <= '9')) { const hex_str = std.fmt.bufPrint(data, "{x} ", .{c}) catch unreachable; return 1 + hex_str.len; } data[0] = c; return 2; } pub const JsApi = struct { pub const bridge = js.Bridge(CSS); pub const Meta = struct { pub const name = "Css"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const escape = bridge.function(CSS.escape, .{}); pub const supports = bridge.function(CSS.supports, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: CSS" { try testing.htmlRunner("css.html", .{}); } ================================================ FILE: src/browser/webapi/Console.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const logger = @import("../../log.zig"); const Console = @This(); _timers: std.StringHashMapUnmanaged(u64) = .{}, _counts: std.StringHashMapUnmanaged(u64) = .{}, pub const init: Console = .{}; pub fn trace(_: *const Console, values: []js.Value, page: *Page) !void { logger.debug(.js, "console.trace", .{ .stack = page.js.local.?.stackTrace() catch "???", .args = ValueWriter{ .page = page, .values = values }, }); } pub fn debug(_: *const Console, values: []js.Value, page: *Page) void { logger.debug(.js, "console.debug", .{ValueWriter{ .page = page, .values = values }}); } pub fn info(_: *const Console, values: []js.Value, page: *Page) void { logger.info(.js, "console.info", .{ValueWriter{ .page = page, .values = values }}); } pub fn log(_: *const Console, values: []js.Value, page: *Page) void { logger.info(.js, "console.log", .{ValueWriter{ .page = page, .values = values }}); } pub fn warn(_: *const Console, values: []js.Value, page: *Page) void { logger.warn(.js, "console.warn", .{ValueWriter{ .page = page, .values = values }}); } pub fn clear(_: *const Console) void {} pub fn assert(_: *const Console, assertion: js.Value, values: []js.Value, page: *Page) void { if (assertion.toBool()) { return; } logger.warn(.js, "console.assert", .{ValueWriter{ .page = page, .values = values }}); } pub fn @"error"(_: *const Console, values: []js.Value, page: *Page) void { logger.warn(.js, "console.error", .{ValueWriter{ .page = page, .values = values, .include_stack = true }}); } pub fn table(_: *const Console, data: js.Value, columns: ?js.Value) void { logger.info(.js, "console.table", .{ .data = data, .columns = columns }); } pub fn count(self: *Console, label_: ?[]const u8, page: *Page) !void { const label = label_ orelse "default"; const gop = try self._counts.getOrPut(page.arena, label); var current: u64 = 0; if (gop.found_existing) { current = gop.value_ptr.*; } else { gop.key_ptr.* = try page.dupeString(label); } const c = current + 1; gop.value_ptr.* = c; logger.info(.js, "console.count", .{ .label = label, .count = c }); } pub fn countReset(self: *Console, label_: ?[]const u8) !void { const label = label_ orelse "default"; const kv = self._counts.fetchRemove(label) orelse { logger.info(.js, "console.countReset", .{ .label = label, .err = "invalid label" }); return; }; logger.info(.js, "console.countReset", .{ .label = label, .count = kv.value }); } pub fn time(self: *Console, label_: ?[]const u8, page: *Page) !void { const label = label_ orelse "default"; const gop = try self._timers.getOrPut(page.arena, label); if (gop.found_existing) { logger.info(.js, "console.time", .{ .label = label, .err = "duplicate timer" }); return; } gop.key_ptr.* = try page.dupeString(label); gop.value_ptr.* = timestamp(); } pub fn timeLog(self: *Console, label_: ?[]const u8) void { const elapsed = timestamp(); const label = label_ orelse "default"; const start = self._timers.get(label) orelse { logger.info(.js, "console.timeLog", .{ .label = label, .err = "invalid timer" }); return; }; logger.info(.js, "console.timeLog", .{ .label = label, .elapsed = elapsed - start }); } pub fn timeEnd(self: *Console, label_: ?[]const u8) void { const elapsed = timestamp(); const label = label_ orelse "default"; const kv = self._timers.fetchRemove(label) orelse { logger.info(.js, "console.timeEnd", .{ .label = label, .err = "invalid timer" }); return; }; logger.info(.js, "console.timeEnd", .{ .label = label, .elapsed = elapsed - kv.value }); } fn timestamp() u64 { return @import("../../datetime.zig").timestamp(.monotonic); } const ValueWriter = struct { page: *Page, values: []js.Value, include_stack: bool = false, pub fn format(self: ValueWriter, writer: *std.io.Writer) !void { for (self.values, 1..) |value, i| { try writer.print("\n arg({d}): {f}", .{ i, value }); } if (self.include_stack) { try writer.print("\n stack: {s}", .{self.page.js.local.?.stackTrace() catch |err| @errorName(err) orelse "???"}); } } pub fn logFmt(self: ValueWriter, _: []const u8, writer: anytype) !void { var buf: [32]u8 = undefined; for (self.values, 0..) |value, i| { const name = try std.fmt.bufPrint(&buf, "param.{d}", .{i}); try writer.write(name, value); } } pub fn jsonStringify(self: ValueWriter, writer: *std.json.Stringify) !void { try writer.beginArray(); for (self.values) |value| { try writer.write(value); } return writer.endArray(); } }; pub const JsApi = struct { pub const bridge = js.Bridge(Console); pub const Meta = struct { pub const name = "Console"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const trace = bridge.function(Console.trace, .{}); pub const debug = bridge.function(Console.debug, .{}); pub const info = bridge.function(Console.info, .{}); pub const log = bridge.function(Console.log, .{}); pub const warn = bridge.function(Console.warn, .{}); pub const clear = bridge.function(Console.clear, .{ .noop = true }); pub const assert = bridge.function(Console.assert, .{}); pub const @"error" = bridge.function(Console.@"error", .{}); pub const exception = bridge.function(Console.@"error", .{}); pub const table = bridge.function(Console.table, .{}); pub const count = bridge.function(Console.count, .{}); pub const countReset = bridge.function(Console.countReset, .{}); pub const time = bridge.function(Console.time, .{}); pub const timeLog = bridge.function(Console.timeLog, .{}); pub const timeEnd = bridge.function(Console.timeEnd, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: Console" { try testing.htmlRunner("console", .{}); } ================================================ FILE: src/browser/webapi/Crypto.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const SubtleCrypto = @import("SubtleCrypto.zig"); const Crypto = @This(); _subtle: SubtleCrypto = .{}, pub const init: Crypto = .{}; // We take a js.Value, because we want to return the same instance, not a new // TypedArray pub fn getRandomValues(_: *const Crypto, js_obj: js.Object) !js.Object { var into = try js_obj.toZig(RandomValues); const buf = into.asBuffer(); if (buf.len > 65_536) { return error.QuotaExceeded; } std.crypto.random.bytes(buf); return js_obj; } pub fn randomUUID(_: *const Crypto) ![36]u8 { var hex: [36]u8 = undefined; @import("../../id.zig").uuidv4(&hex); return hex; } pub fn getSubtle(self: *Crypto) *SubtleCrypto { return &self._subtle; } const RandomValues = union(enum) { int8: []i8, uint8: []u8, int16: []i16, uint16: []u16, int32: []i32, uint32: []u32, int64: []i64, uint64: []u64, fn asBuffer(self: RandomValues) []u8 { return switch (self) { .int8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len], .uint8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len], .int16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2], .uint16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2], .int32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4], .uint32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4], .int64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8], .uint64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8], }; } }; pub const JsApi = struct { pub const bridge = js.Bridge(Crypto); pub const Meta = struct { pub const name = "Crypto"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{ .dom_exception = true }); pub const randomUUID = bridge.function(Crypto.randomUUID, .{}); pub const subtle = bridge.accessor(Crypto.getSubtle, null, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: Crypto" { try testing.htmlRunner("crypto.html", .{}); } ================================================ FILE: src/browser/webapi/CustomElementDefinition.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Element = @import("Element.zig"); const CustomElementDefinition = @This(); name: []const u8, constructor: js.Function.Global, // TODO: Make this a Map observed_attributes: std.StringHashMapUnmanaged(void) = .{}, // For customized built-in elements, this is the element tag they extend (e.g., .button) // For autonomous custom elements, this is null extends: ?Element.Tag = null, pub fn isAttributeObserved(self: *const CustomElementDefinition, name: String) bool { return self.observed_attributes.contains(name.str()); } pub fn isAutonomous(self: *const CustomElementDefinition) bool { return self.extends == null; } pub fn isCustomizedBuiltIn(self: *const CustomElementDefinition) bool { return self.extends != null; } ================================================ FILE: src/browser/webapi/CustomElementRegistry.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const log = @import("../../log.zig"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); const DOMException = @import("DOMException.zig"); const Custom = @import("element/html/Custom.zig"); const CustomElementDefinition = @import("CustomElementDefinition.zig"); const CustomElementRegistry = @This(); _definitions: std.StringHashMapUnmanaged(*CustomElementDefinition) = .{}, _when_defined: std.StringHashMapUnmanaged(js.PromiseResolver.Global) = .{}, const DefineOptions = struct { extends: ?[]const u8 = null, }; pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Function, options_: ?DefineOptions, page: *Page) !void { const options = options_ orelse DefineOptions{}; try validateName(name); // Parse and validate extends option const extends_tag: ?Element.Tag = if (options.extends) |extends_name| blk: { const tag = std.meta.stringToEnum(Element.Tag, extends_name) orelse return error.NotSupported; // Can't extend custom elements if (tag == .custom) { return error.NotSupported; } break :blk tag; } else null; const gop = try self._definitions.getOrPut(page.arena, name); if (gop.found_existing) { // Yes, this is the correct error to return when trying to redefine a name return error.NotSupported; } const owned_name = try page.dupeString(name); const definition = try page._factory.create(CustomElementDefinition{ .name = owned_name, .constructor = try constructor.persist(), .extends = extends_tag, }); // Read observedAttributes static property from constructor if (constructor.getPropertyValue("observedAttributes") catch null) |observed_attrs| { if (observed_attrs.isArray()) { var js_arr = observed_attrs.toArray(); for (0..js_arr.len()) |i| { const attr_val = js_arr.get(@intCast(i)) catch continue; const attr_name = attr_val.toStringSliceWithAlloc(page.arena) catch continue; definition.observed_attributes.put(page.arena, attr_name, {}) catch continue; } } } gop.key_ptr.* = owned_name; gop.value_ptr.* = definition; // Upgrade any undefined custom elements with this name var idx: usize = 0; while (idx < page._undefined_custom_elements.items.len) { const custom = page._undefined_custom_elements.items[idx]; if (!custom._tag_name.eqlSlice(name)) { idx += 1; continue; } if (!custom.asElement().asNode().isConnected()) { idx += 1; continue; } upgradeCustomElement(custom, definition, page) catch { _ = page._undefined_custom_elements.swapRemove(idx); continue; }; _ = page._undefined_custom_elements.swapRemove(idx); } if (self._when_defined.fetchRemove(name)) |entry| { page.js.toLocal(entry.value).resolve("whenDefined", constructor); } } pub fn get(self: *CustomElementRegistry, name: []const u8) ?js.Function.Global { const definition = self._definitions.get(name) orelse return null; return definition.constructor; } pub fn upgrade(self: *CustomElementRegistry, root: *Node, page: *Page) !void { try upgradeNode(self, root, page); } pub fn whenDefined(self: *CustomElementRegistry, name: []const u8, page: *Page) !js.Promise { const local = page.js.local.?; if (self._definitions.get(name)) |definition| { return local.resolvePromise(definition.constructor); } validateName(name) catch |err| { return local.rejectPromise(DOMException.fromError(err) orelse unreachable); }; const gop = try self._when_defined.getOrPut(page.arena, name); if (gop.found_existing) { return local.toLocal(gop.value_ptr.*).promise(); } errdefer _ = self._when_defined.remove(name); const owned_name = try page.dupeString(name); const resolver = local.createPromiseResolver(); gop.key_ptr.* = owned_name; gop.value_ptr.* = try resolver.persist(); return resolver.promise(); } fn upgradeNode(self: *CustomElementRegistry, node: *Node, page: *Page) !void { if (node.is(Element)) |element| { try upgradeElement(self, element, page); } var it = node.childrenIterator(); while (it.next()) |child| { try upgradeNode(self, child, page); } } fn upgradeElement(self: *CustomElementRegistry, element: *Element, page: *Page) !void { const custom = element.is(Custom) orelse { return Custom.checkAndAttachBuiltIn(element, page); }; if (custom._definition != null) return; const name = custom._tag_name.str(); const definition = self._definitions.get(name) orelse return; try upgradeCustomElement(custom, definition, page); } pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinition, page: *Page) !void { custom._definition = definition; // Reset callback flags since this is a fresh upgrade custom._connected_callback_invoked = false; custom._disconnected_callback_invoked = false; const node = custom.asNode(); const prev_upgrading = page._upgrading_element; page._upgrading_element = node; defer page._upgrading_element = prev_upgrading; var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var caught: js.TryCatch.Caught = undefined; _ = ls.toLocal(definition.constructor).newInstance(&caught) catch |err| { log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err, .caught = caught }); return error.CustomElementUpgradeFailed; }; // Invoke attributeChangedCallback for existing observed attributes var attr_it = custom.asElement().attributeIterator(); while (attr_it.next()) |attr| { const name = attr._name; if (definition.isAttributeObserved(name)) { custom.invokeAttributeChangedCallback(name, null, attr._value, page); } } if (node.isConnected()) { custom.invokeConnectedCallback(page); } } fn validateName(name: []const u8) !void { if (name.len == 0) { return error.SyntaxError; } if (std.mem.indexOf(u8, name, "-") == null) { return error.SyntaxError; } if (name[0] < 'a' or name[0] > 'z') { return error.SyntaxError; } const reserved_names = [_][]const u8{ "annotation-xml", "color-profile", "font-face", "font-face-src", "font-face-uri", "font-face-format", "font-face-name", "missing-glyph", }; for (reserved_names) |reserved| { if (std.mem.eql(u8, name, reserved)) { return error.SyntaxError; } } for (name) |c| { if (c >= 'A' and c <= 'Z') { return error.SyntaxError; } // Reject control characters and specific invalid characters // per elementLocalNameRegex: [^\0\t\n\f\r\u0020/>]* switch (c) { 0, '\t', '\n', '\r', 0x0C, ' ', '/', '>' => return error.SyntaxError, else => {}, } } } pub const JsApi = struct { pub const bridge = js.Bridge(CustomElementRegistry); pub const Meta = struct { pub const name = "CustomElementRegistry"; pub var class_id: bridge.ClassId = undefined; pub const prototype_chain = bridge.prototypeChain(); }; pub const define = bridge.function(CustomElementRegistry.define, .{ .dom_exception = true }); pub const get = bridge.function(CustomElementRegistry.get, .{ .null_as_undefined = true }); pub const upgrade = bridge.function(CustomElementRegistry.upgrade, .{}); pub const whenDefined = bridge.function(CustomElementRegistry.whenDefined, .{ .dom_exception = true }); }; const testing = @import("../../testing.zig"); test "WebApi: CustomElementRegistry" { try testing.htmlRunner("custom_elements", .{}); } ================================================ FILE: src/browser/webapi/DOMException.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const DOMException = @This(); _code: Code = .none, _custom_name: ?[]const u8 = null, _custom_message: ?[]const u8 = null, pub fn init(message: ?[]const u8, name: ?[]const u8) DOMException { // If name is provided, try to map it to a legacy code const code = if (name) |n| Code.fromName(n) else .none; return .{ ._code = code, ._custom_name = name, ._custom_message = message, }; } pub fn fromError(err: anyerror) ?DOMException { return switch (err) { error.SyntaxError => .{ ._code = .syntax_error }, error.InvalidCharacterError => .{ ._code = .invalid_character_error }, error.NotFound => .{ ._code = .not_found }, error.NotSupported => .{ ._code = .not_supported }, error.HierarchyError => .{ ._code = .hierarchy_error }, error.IndexSizeError => .{ ._code = .index_size_error }, error.InvalidStateError => .{ ._code = .invalid_state_error }, error.WrongDocument => .{ ._code = .wrong_document_error }, error.NoModificationAllowed => .{ ._code = .no_modification_allowed_error }, error.InUseAttribute => .{ ._code = .inuse_attribute_error }, error.InvalidModification => .{ ._code = .invalid_modification_error }, error.NamespaceError => .{ ._code = .namespace_error }, error.InvalidAccess => .{ ._code = .invalid_access_error }, error.SecurityError => .{ ._code = .security_error }, error.NetworkError => .{ ._code = .network_error }, error.AbortError => .{ ._code = .abort_error }, error.URLMismatch => .{ ._code = .url_mismatch_error }, error.QuotaExceeded => .{ ._code = .quota_exceeded_error }, error.TimeoutError => .{ ._code = .timeout_error }, error.InvalidNodeType => .{ ._code = .invalid_node_type_error }, error.DataClone => .{ ._code = .data_clone_error }, else => null, }; } pub fn getCode(self: *const DOMException) u8 { return @intFromEnum(self._code); } pub fn getName(self: *const DOMException) []const u8 { if (self._custom_name) |name| { return name; } return switch (self._code) { .none => "Error", .index_size_error => "IndexSizeError", .hierarchy_error => "HierarchyRequestError", .wrong_document_error => "WrongDocumentError", .invalid_character_error => "InvalidCharacterError", .no_modification_allowed_error => "NoModificationAllowedError", .not_found => "NotFoundError", .not_supported => "NotSupportedError", .inuse_attribute_error => "InUseAttributeError", .invalid_state_error => "InvalidStateError", .syntax_error => "SyntaxError", .invalid_modification_error => "InvalidModificationError", .namespace_error => "NamespaceError", .invalid_access_error => "InvalidAccessError", .security_error => "SecurityError", .network_error => "NetworkError", .abort_error => "AbortError", .url_mismatch_error => "URLMismatchError", .quota_exceeded_error => "QuotaExceededError", .timeout_error => "TimeoutError", .invalid_node_type_error => "InvalidNodeTypeError", .data_clone_error => "DataCloneError", }; } pub fn getMessage(self: *const DOMException) []const u8 { if (self._custom_message) |msg| { return msg; } return switch (self._code) { .none => "", .index_size_error => "Index or size is negative or greater than the allowed amount", .hierarchy_error => "The operation would yield an incorrect node tree", .wrong_document_error => "The object is in the wrong document", .invalid_character_error => "The string contains invalid characters", .no_modification_allowed_error => "The object can not be modified", .not_found => "The object can not be found here", .not_supported => "The operation is not supported", .inuse_attribute_error => "The attribute already in use", .invalid_state_error => "The object is in an invalid state", .syntax_error => "The string did not match the expected pattern", .invalid_modification_error => "The object can not be modified in this way", .namespace_error => "The operation is not allowed by Namespaces in XML", .invalid_access_error => "The object does not support the operation or argument", .security_error => "The operation is insecure", .network_error => "A network error occurred", .abort_error => "The operation was aborted", .url_mismatch_error => "The given URL does not match another URL", .quota_exceeded_error => "The quota has been exceeded", .timeout_error => "The operation timed out", .invalid_node_type_error => "The supplied node is incorrect or has an incorrect ancestor for this operation", .data_clone_error => "The object can not be cloned", }; } pub fn toString(self: *const DOMException, page: *Page) ![]const u8 { const msg = blk: { if (self._custom_message) |msg| { break :blk msg; } switch (self._code) { .none => return "Error", else => break :blk self.getMessage(), } }; return std.fmt.bufPrint(&page.buf, "{s}: {s}", .{ self.getName(), msg }) catch return msg; } const Code = enum(u8) { none = 0, index_size_error = 1, hierarchy_error = 3, wrong_document_error = 4, invalid_character_error = 5, no_modification_allowed_error = 7, not_found = 8, not_supported = 9, inuse_attribute_error = 10, invalid_state_error = 11, syntax_error = 12, invalid_modification_error = 13, namespace_error = 14, invalid_access_error = 15, security_error = 18, network_error = 19, abort_error = 20, url_mismatch_error = 21, quota_exceeded_error = 22, timeout_error = 23, invalid_node_type_error = 24, data_clone_error = 25, /// Maps a standard error name to its legacy code /// Returns .none (code 0) for non-legacy error names pub fn fromName(name: []const u8) Code { const lookup = std.StaticStringMap(Code).initComptime(.{ .{ "IndexSizeError", .index_size_error }, .{ "HierarchyRequestError", .hierarchy_error }, .{ "WrongDocumentError", .wrong_document_error }, .{ "InvalidCharacterError", .invalid_character_error }, .{ "NoModificationAllowedError", .no_modification_allowed_error }, .{ "NotFoundError", .not_found }, .{ "NotSupportedError", .not_supported }, .{ "InUseAttributeError", .inuse_attribute_error }, .{ "InvalidStateError", .invalid_state_error }, .{ "SyntaxError", .syntax_error }, .{ "InvalidModificationError", .invalid_modification_error }, .{ "NamespaceError", .namespace_error }, .{ "InvalidAccessError", .invalid_access_error }, .{ "SecurityError", .security_error }, .{ "NetworkError", .network_error }, .{ "AbortError", .abort_error }, .{ "URLMismatchError", .url_mismatch_error }, .{ "QuotaExceededError", .quota_exceeded_error }, .{ "TimeoutError", .timeout_error }, .{ "InvalidNodeTypeError", .invalid_node_type_error }, .{ "DataCloneError", .data_clone_error }, }); return lookup.get(name) orelse .none; } }; pub const JsApi = struct { pub const bridge = js.Bridge(DOMException); pub const Meta = struct { pub const name = "DOMException"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(DOMException.init, .{}); pub const code = bridge.accessor(DOMException.getCode, null, .{}); pub const name = bridge.accessor(DOMException.getName, null, .{}); pub const message = bridge.accessor(DOMException.getMessage, null, .{}); pub const toString = bridge.function(DOMException.toString, .{}); // Legacy error code constants (on both prototype and constructor) pub const INDEX_SIZE_ERR = bridge.property(1, .{ .template = true }); pub const DOMSTRING_SIZE_ERR = bridge.property(2, .{ .template = true }); pub const HIERARCHY_REQUEST_ERR = bridge.property(3, .{ .template = true }); pub const WRONG_DOCUMENT_ERR = bridge.property(4, .{ .template = true }); pub const INVALID_CHARACTER_ERR = bridge.property(5, .{ .template = true }); pub const NO_DATA_ALLOWED_ERR = bridge.property(6, .{ .template = true }); pub const NO_MODIFICATION_ALLOWED_ERR = bridge.property(7, .{ .template = true }); pub const NOT_FOUND_ERR = bridge.property(8, .{ .template = true }); pub const NOT_SUPPORTED_ERR = bridge.property(9, .{ .template = true }); pub const INUSE_ATTRIBUTE_ERR = bridge.property(10, .{ .template = true }); pub const INVALID_STATE_ERR = bridge.property(11, .{ .template = true }); pub const SYNTAX_ERR = bridge.property(12, .{ .template = true }); pub const INVALID_MODIFICATION_ERR = bridge.property(13, .{ .template = true }); pub const NAMESPACE_ERR = bridge.property(14, .{ .template = true }); pub const INVALID_ACCESS_ERR = bridge.property(15, .{ .template = true }); pub const VALIDATION_ERR = bridge.property(16, .{ .template = true }); pub const TYPE_MISMATCH_ERR = bridge.property(17, .{ .template = true }); pub const SECURITY_ERR = bridge.property(18, .{ .template = true }); pub const NETWORK_ERR = bridge.property(19, .{ .template = true }); pub const ABORT_ERR = bridge.property(20, .{ .template = true }); pub const URL_MISMATCH_ERR = bridge.property(21, .{ .template = true }); pub const QUOTA_EXCEEDED_ERR = bridge.property(22, .{ .template = true }); pub const TIMEOUT_ERR = bridge.property(23, .{ .template = true }); pub const INVALID_NODE_TYPE_ERR = bridge.property(24, .{ .template = true }); pub const DATA_CLONE_ERR = bridge.property(25, .{ .template = true }); }; const testing = @import("../../testing.zig"); test "WebApi: DOMException" { try testing.htmlRunner("domexception.html", .{}); } ================================================ FILE: src/browser/webapi/DOMImplementation.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const Document = @import("Document.zig"); const DocumentType = @import("DocumentType.zig"); const DOMImplementation = @This(); _pad: bool = false, pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, page: *Page) !*DocumentType { return DocumentType.init(qualified_name, public_id, system_id, page); } pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString, page: *Page) !*Document { const document = (try page._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument(); document._ready_state = .complete; document._url = "about:blank"; { const doctype = try page._factory.node(DocumentType{ ._proto = undefined, ._name = "html", ._public_id = "", ._system_id = "", }); _ = try document.asNode().appendChild(doctype.asNode(), page); } const html_node = try page.createElementNS(.html, "html", null); _ = try document.asNode().appendChild(html_node, page); const head_node = try page.createElementNS(.html, "head", null); _ = try html_node.appendChild(head_node, page); if (title) |t| { const title_node = try page.createElementNS(.html, "title", null); _ = try head_node.appendChild(title_node, page); const text_node = try page.createTextNode(t.value); _ = try title_node.appendChild(text_node, page); } const body_node = try page.createElementNS(.html, "body", null); _ = try html_node.appendChild(body_node, page); return document; } pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qualified_name: ?[]const u8, doctype: ?*DocumentType, page: *Page) !*Document { // Create XML Document const document = (try page._factory.document(Node.Document.XMLDocument{ ._proto = undefined })).asDocument(); document._url = "about:blank"; // Append doctype if provided if (doctype) |dt| { _ = try document.asNode().appendChild(dt.asNode(), page); } // Create and append root element if qualified_name provided if (qualified_name) |qname| { if (qname.len > 0) { const namespace = if (namespace_) |ns| Node.Element.Namespace.parse(ns) else .xml; const root = try page.createElementNS(namespace, qname, null); _ = try document.asNode().appendChild(root, page); } } return document; } pub fn hasFeature(_: *const DOMImplementation, _: ?[]const u8, _: ?[]const u8) bool { // Modern DOM spec says this should always return true // This method is deprecated and kept for compatibility only return true; } pub const JsApi = struct { pub const bridge = js.Bridge(DOMImplementation); pub const Meta = struct { pub const name = "DOMImplementation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; pub const enumerable = false; }; pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{ .dom_exception = true }); pub const createDocument = bridge.function(DOMImplementation.createDocument, .{}); pub const createHTMLDocument = bridge.function(DOMImplementation.createHTMLDocument, .{}); pub const hasFeature = bridge.function(DOMImplementation.hasFeature, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: DOMImplementation" { try testing.htmlRunner("domimplementation.html", .{}); } ================================================ FILE: src/browser/webapi/DOMNodeIterator.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const NodeFilter = @import("NodeFilter.zig"); pub const FilterOpts = NodeFilter.FilterOpts; const DOMNodeIterator = @This(); _root: *Node, _what_to_show: u32, _filter: NodeFilter, _reference_node: *Node, _pointer_before_reference_node: bool, _active: bool = false, pub fn init(root: *Node, what_to_show: u32, filter: ?FilterOpts, page: *Page) !*DOMNodeIterator { const node_filter = try NodeFilter.init(filter); return page._factory.create(DOMNodeIterator{ ._root = root, ._filter = node_filter, ._reference_node = root, ._what_to_show = what_to_show, ._pointer_before_reference_node = true, }); } pub fn getRoot(self: *const DOMNodeIterator) *Node { return self._root; } pub fn getReferenceNode(self: *const DOMNodeIterator) *Node { return self._reference_node; } pub fn getPointerBeforeReferenceNode(self: *const DOMNodeIterator) bool { return self._pointer_before_reference_node; } pub fn getWhatToShow(self: *const DOMNodeIterator) u32 { return self._what_to_show; } pub fn getFilter(self: *const DOMNodeIterator) ?FilterOpts { return self._filter._original_filter; } pub fn nextNode(self: *DOMNodeIterator, page: *Page) !?*Node { if (self._active) { return error.InvalidStateError; } self._active = true; defer self._active = false; var node = self._reference_node; var before_node = self._pointer_before_reference_node; while (true) { if (before_node) { before_node = false; const result = try self.filterNode(node, page); if (result == NodeFilter.FILTER_ACCEPT) { self._reference_node = node; self._pointer_before_reference_node = false; return node; } } else { // Move to next node in tree order const next = self.getNextInTree(node); if (next == null) { return null; } node = next.?; const result = try self.filterNode(node, page); if (result == NodeFilter.FILTER_ACCEPT) { self._reference_node = node; self._pointer_before_reference_node = false; return node; } } } } pub fn previousNode(self: *DOMNodeIterator, page: *Page) !?*Node { if (self._active) { return error.InvalidStateError; } self._active = true; defer self._active = false; var node = self._reference_node; var before_node = self._pointer_before_reference_node; while (true) { if (!before_node) { const result = try self.filterNode(node, page); if (result == NodeFilter.FILTER_ACCEPT) { self._reference_node = node; self._pointer_before_reference_node = true; return node; } before_node = true; } // Move to previous node in tree order const prev = self.getPreviousInTree(node); if (prev == null) { return null; } node = prev.?; before_node = false; } } pub fn detach(_: *const DOMNodeIterator) void { // no-op legacy } fn filterNode(self: *const DOMNodeIterator, node: *Node, page: *Page) !i32 { // First check whatToShow if (!NodeFilter.shouldShow(node, self._what_to_show)) { return NodeFilter.FILTER_SKIP; } // Then check the filter callback // For NodeIterator, REJECT and SKIP are equivalent - both skip the node // but continue with its descendants const result = try self._filter.acceptNode(node, page.js.local.?); return result; } fn getNextInTree(self: *const DOMNodeIterator, node: *Node) ?*Node { // Depth-first traversal within the root subtree if (node._children) |children| { return children.first(); } var current = node; while (current != self._root) { if (current.nextSibling()) |sibling| { return sibling; } current = current._parent orelse return null; } return null; } fn getPreviousInTree(self: *const DOMNodeIterator, node: *Node) ?*Node { if (node == self._root) { return null; } if (node.previousSibling()) |sibling| { // Go to the last descendant of the sibling var last = sibling; while (last.lastChild()) |child| { last = child; } return last; } return node._parent; } pub const JsApi = struct { pub const bridge = js.Bridge(DOMNodeIterator); pub const Meta = struct { pub const name = "NodeIterator"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const root = bridge.accessor(DOMNodeIterator.getRoot, null, .{}); pub const referenceNode = bridge.accessor(DOMNodeIterator.getReferenceNode, null, .{}); pub const pointerBeforeReferenceNode = bridge.accessor(DOMNodeIterator.getPointerBeforeReferenceNode, null, .{}); pub const whatToShow = bridge.accessor(DOMNodeIterator.getWhatToShow, null, .{}); pub const filter = bridge.accessor(DOMNodeIterator.getFilter, null, .{}); pub const nextNode = bridge.function(DOMNodeIterator.nextNode, .{ .dom_exception = true }); pub const previousNode = bridge.function(DOMNodeIterator.previousNode, .{ .dom_exception = true }); pub const detach = bridge.function(DOMNodeIterator.detach, .{}); }; ================================================ FILE: src/browser/webapi/DOMParser.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Parser = @import("../parser/Parser.zig"); const HTMLDocument = @import("HTMLDocument.zig"); const XMLDocument = @import("XMLDocument.zig"); const Document = @import("Document.zig"); const DOMParser = @This(); // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, pub fn init() DOMParser { return .{}; } pub fn parseFromString( _: *const DOMParser, html: []const u8, mime_type: []const u8, page: *Page, ) !*Document { const target_mime = std.meta.stringToEnum(enum { @"text/html", @"text/xml", @"application/xml", @"application/xhtml+xml", @"image/svg+xml", }, mime_type) orelse return error.NotSupported; const arena = try page.getArena(.{ .debug = "DOMParser.parseFromString" }); defer page.releaseArena(arena); return switch (target_mime) { .@"text/html" => { // Create a new HTMLDocument const doc = try page._factory.document(HTMLDocument{ ._proto = undefined, }); var normalized = std.mem.trim(u8, html, &std.ascii.whitespace); if (normalized.len == 0) { normalized = ""; } // Parse HTML into the document var parser = Parser.init(arena, doc.asNode(), page); parser.parse(normalized); if (parser.err) |pe| { return pe.err; } return doc.asDocument(); }, else => { // Create a new XMLDocument. const doc = try page._factory.document(XMLDocument{ ._proto = undefined, }); // Parse XML into XMLDocument. const doc_node = doc.asNode(); var parser = Parser.init(arena, doc_node, page); parser.parseXML(html); if (parser.err != null or doc_node.firstChild() == null) { // Return a document with a element per spec. const err_doc = try page._factory.document(XMLDocument{ ._proto = undefined }); var err_parser = Parser.init(arena, err_doc.asNode(), page); err_parser.parseXML("error"); return err_doc.asDocument(); } const first_child = doc_node.firstChild().?; // If first node is a `ProcessingInstruction`, skip it. if (first_child.getNodeType() == 7) { // We're sure that firstChild exist, this cannot fail. _ = try doc_node.removeChild(first_child, page); } return doc.asDocument(); }, }; } pub const JsApi = struct { pub const bridge = js.Bridge(DOMParser); pub const Meta = struct { pub const name = "DOMParser"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const constructor = bridge.constructor(DOMParser.init, .{}); pub const parseFromString = bridge.function(DOMParser.parseFromString, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: DOMParser" { try testing.htmlRunner("domparser.html", .{}); } ================================================ FILE: src/browser/webapi/DOMRect.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const DOMRect = @This(); const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); _x: f64, _y: f64, _width: f64, _height: f64, pub fn init(x: f64, y: f64, width: f64, height: f64, page: *Page) !*DOMRect { return page._factory.create(DOMRect{ ._x = x, ._y = y, ._width = width, ._height = height, }); } pub fn getX(self: *const DOMRect) f64 { return self._x; } pub fn getY(self: *const DOMRect) f64 { return self._y; } pub fn getWidth(self: *const DOMRect) f64 { return self._width; } pub fn getHeight(self: *const DOMRect) f64 { return self._height; } pub fn getTop(self: *const DOMRect) f64 { return @min(self._y, self._y + self._height); } pub fn getRight(self: *const DOMRect) f64 { return @max(self._x, self._x + self._width); } pub fn getBottom(self: *const DOMRect) f64 { return @max(self._y, self._y + self._height); } pub fn getLeft(self: *const DOMRect) f64 { return @min(self._x, self._x + self._width); } pub const JsApi = struct { pub const bridge = js.Bridge(DOMRect); pub const Meta = struct { pub const name = "DOMRect"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(DOMRect.init, .{}); pub const x = bridge.accessor(DOMRect.getX, null, .{}); pub const y = bridge.accessor(DOMRect.getY, null, .{}); pub const width = bridge.accessor(DOMRect.getWidth, null, .{}); pub const height = bridge.accessor(DOMRect.getHeight, null, .{}); pub const top = bridge.accessor(DOMRect.getTop, null, .{}); pub const right = bridge.accessor(DOMRect.getRight, null, .{}); pub const bottom = bridge.accessor(DOMRect.getBottom, null, .{}); pub const left = bridge.accessor(DOMRect.getLeft, null, .{}); }; ================================================ FILE: src/browser/webapi/DOMTreeWalker.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const NodeFilter = @import("NodeFilter.zig"); pub const FilterOpts = NodeFilter.FilterOpts; const DOMTreeWalker = @This(); _root: *Node, _what_to_show: u32, _filter: NodeFilter, _current: *Node, pub fn init(root: *Node, what_to_show: u32, filter: ?FilterOpts, page: *Page) !*DOMTreeWalker { const node_filter = try NodeFilter.init(filter); return page._factory.create(DOMTreeWalker{ ._root = root, ._current = root, ._filter = node_filter, ._what_to_show = what_to_show, }); } pub fn getRoot(self: *const DOMTreeWalker) *Node { return self._root; } pub fn getWhatToShow(self: *const DOMTreeWalker) u32 { return self._what_to_show; } pub fn getFilter(self: *const DOMTreeWalker) ?FilterOpts { return self._filter._original_filter; } pub fn getCurrentNode(self: *const DOMTreeWalker) *Node { return self._current; } pub fn setCurrentNode(self: *DOMTreeWalker, node: *Node) void { self._current = node; } // Navigation methods pub fn parentNode(self: *DOMTreeWalker, page: *Page) !?*Node { var node = self._current._parent; while (node) |n| { if (n == self._root._parent) { return null; } if (try self.acceptNode(n, page) == NodeFilter.FILTER_ACCEPT) { self._current = n; return n; } node = n._parent; } return null; } pub fn firstChild(self: *DOMTreeWalker, page: *Page) !?*Node { var node = self._current.firstChild(); while (node) |n| { const filter_result = try self.acceptNode(n, page); if (filter_result == NodeFilter.FILTER_ACCEPT) { self._current = n; return n; } if (filter_result == NodeFilter.FILTER_SKIP) { // Descend into children of this skipped node if (n.firstChild()) |child| { node = child; continue; } } // REJECT or SKIP with no children - find next sibling, walking up if necessary var current_node = n; while (true) { if (current_node.nextSibling()) |sibling| { node = sibling; break; } // No sibling, go up to parent const parent = current_node._parent orelse return null; if (parent == self._current) { // We've exhausted all children of self._current return null; } current_node = parent; } } return null; } pub fn lastChild(self: *DOMTreeWalker, page: *Page) !?*Node { var node = self._current.lastChild(); while (node) |n| { const filter_result = try self.acceptNode(n, page); if (filter_result == NodeFilter.FILTER_ACCEPT) { self._current = n; return n; } if (filter_result == NodeFilter.FILTER_SKIP) { // Descend into children of this skipped node if (n.lastChild()) |child| { node = child; continue; } } // REJECT or SKIP with no children - find previous sibling, walking up if necessary var current_node = n; while (true) { if (current_node.previousSibling()) |sibling| { node = sibling; break; } // No sibling, go up to parent const parent = current_node._parent orelse return null; if (parent == self._current) { // We've exhausted all children of self._current return null; } current_node = parent; } } return null; } pub fn previousSibling(self: *DOMTreeWalker, page: *Page) !?*Node { var node = self.previousSiblingOrNull(self._current); while (node) |n| { if (try self.acceptNode(n, page) == NodeFilter.FILTER_ACCEPT) { self._current = n; return n; } node = self.previousSiblingOrNull(n); } return null; } pub fn nextSibling(self: *DOMTreeWalker, page: *Page) !?*Node { var node = self.nextSiblingOrNull(self._current); while (node) |n| { if (try self.acceptNode(n, page) == NodeFilter.FILTER_ACCEPT) { self._current = n; return n; } node = self.nextSiblingOrNull(n); } return null; } pub fn previousNode(self: *DOMTreeWalker, page: *Page) !?*Node { var node = self._current; while (node != self._root) { var sibling = self.previousSiblingOrNull(node); while (sibling) |sib| { node = sib; // Check if this sibling is rejected before descending into it const sib_result = try self.acceptNode(node, page); if (sib_result == NodeFilter.FILTER_REJECT) { // Skip this sibling and its descendants entirely sibling = self.previousSiblingOrNull(node); continue; } // Descend to the deepest last child, but respect FILTER_REJECT while (true) { var child = self.lastChildOrNull(node); // Find the rightmost non-rejected child while (child) |c| { if (!self.isInSubtree(c)) break; const filter_result = try self.acceptNode(c, page); if (filter_result == NodeFilter.FILTER_REJECT) { // Skip this child and try its previous sibling child = self.previousSiblingOrNull(c); } else { // ACCEPT or SKIP - use this child break; } } if (child == null) break; // No acceptable children // Descend into this child node = child.?; } if (try self.acceptNode(node, page) == NodeFilter.FILTER_ACCEPT) { self._current = node; return node; } sibling = self.previousSiblingOrNull(node); } if (node == self._root) { return null; } const parent = node._parent orelse return null; if (try self.acceptNode(parent, page) == NodeFilter.FILTER_ACCEPT) { self._current = parent; return parent; } node = parent; } return null; } pub fn nextNode(self: *DOMTreeWalker, page: *Page) !?*Node { var node = self._current; while (true) { // Try children first (depth-first) if (node.firstChild()) |child| { node = child; const filter_result = try self.acceptNode(node, page); if (filter_result == NodeFilter.FILTER_ACCEPT) { self._current = node; return node; } // If REJECT, skip this entire subtree; if SKIP, try children if (filter_result == NodeFilter.FILTER_REJECT) { // Skip this node and its children - continue with siblings // Don't update node, will try siblings below } else { // SKIP - already moved to child, will try its children on next iteration continue; } } // No (more) children, try siblings while (true) { if (node == self._root) { return null; } if (node.nextSibling()) |sibling| { node = sibling; const filter_result = try self.acceptNode(node, page); if (filter_result == NodeFilter.FILTER_ACCEPT) { self._current = node; return node; } // If REJECT, skip subtree; if SKIP, try children if (filter_result == NodeFilter.FILTER_REJECT) { // Continue sibling loop to get next sibling continue; } else { // SKIP - try this node's children break; } } // No sibling, go up to parent node = node._parent orelse return null; } } } // Helper methods fn acceptNode(self: *const DOMTreeWalker, node: *Node, page: *Page) !i32 { // First check whatToShow if (!NodeFilter.shouldShow(node, self._what_to_show)) { return NodeFilter.FILTER_SKIP; } // Then check the filter callback // For TreeWalker, REJECT means reject node and its descendants // SKIP means skip node but check its descendants // ACCEPT means accept the node return try self._filter.acceptNode(node, page.js.local.?); } fn isInSubtree(self: *const DOMTreeWalker, node: *Node) bool { var current = node; while (current._parent) |parent| { if (parent == self._root) { return true; } current = parent; } return current == self._root; } fn firstChildOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node { _ = self; return node.firstChild(); } fn lastChildOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node { _ = self; return node.lastChild(); } fn nextSiblingOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node { _ = self; return node.nextSibling(); } fn previousSiblingOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node { _ = self; return node.previousSibling(); } pub const JsApi = struct { pub const bridge = js.Bridge(DOMTreeWalker); pub const Meta = struct { pub const name = "TreeWalker"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const root = bridge.accessor(DOMTreeWalker.getRoot, null, .{}); pub const whatToShow = bridge.accessor(DOMTreeWalker.getWhatToShow, null, .{}); pub const filter = bridge.accessor(DOMTreeWalker.getFilter, null, .{}); pub const currentNode = bridge.accessor(DOMTreeWalker.getCurrentNode, DOMTreeWalker.setCurrentNode, .{}); pub const parentNode = bridge.function(DOMTreeWalker.parentNode, .{}); pub const firstChild = bridge.function(DOMTreeWalker.firstChild, .{}); pub const lastChild = bridge.function(DOMTreeWalker.lastChild, .{}); pub const previousSibling = bridge.function(DOMTreeWalker.previousSibling, .{}); pub const nextSibling = bridge.function(DOMTreeWalker.nextSibling, .{}); pub const previousNode = bridge.function(DOMTreeWalker.previousNode, .{}); pub const nextNode = bridge.function(DOMTreeWalker.nextNode, .{}); }; ================================================ FILE: src/browser/webapi/Document.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const log = @import("../../log.zig"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const URL = @import("../URL.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); const Location = @import("Location.zig"); const Parser = @import("../parser/Parser.zig"); const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); const DOMTreeWalker = @import("DOMTreeWalker.zig"); const DOMNodeIterator = @import("DOMNodeIterator.zig"); const DOMImplementation = @import("DOMImplementation.zig"); const StyleSheetList = @import("css/StyleSheetList.zig"); const FontFaceSet = @import("css/FontFaceSet.zig"); const Selection = @import("Selection.zig"); pub const XMLDocument = @import("XMLDocument.zig"); pub const HTMLDocument = @import("HTMLDocument.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const Document = @This(); _type: Type, _proto: *Node, _page: ?*Page = null, _location: ?*Location = null, _url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank) _ready_state: ReadyState = .loading, _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, // Track IDs that were removed from the map - they might have duplicates in the tree _removed_ids: std.StringHashMapUnmanaged(void) = .empty, _active_element: ?*Element = null, _style_sheets: ?*StyleSheetList = null, _implementation: ?*DOMImplementation = null, _fonts: ?*FontFaceSet = null, _write_insertion_point: ?*Node = null, _script_created_parser: ?Parser.Streaming = null, _adopted_style_sheets: ?js.Object.Global = null, _selection: Selection = .init, // https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#throw-on-dynamic-markup-insertion-counter // Incremented during custom element reactions when parsing. When > 0, // document.open/close/write/writeln must throw InvalidStateError. _throw_on_dynamic_markup_insertion_counter: u32 = 0, _on_selectionchange: ?js.Function.Global = null, pub fn getOnSelectionChange(self: *Document) ?js.Function.Global { return self._on_selectionchange; } pub fn setOnSelectionChange(self: *Document, listener: ?js.Function) !void { if (listener) |listen| { self._on_selectionchange = try listen.persistWithThis(self); } else { self._on_selectionchange = null; } } pub const Type = union(enum) { generic, html: *HTMLDocument, xml: *XMLDocument, }; pub fn is(self: *Document, comptime T: type) ?*T { switch (self._type) { .html => |html| { if (T == HTMLDocument) { return html; } }, .xml => |xml| { if (T == XMLDocument) { return xml; } }, .generic => {}, } return null; } pub fn as(self: *Document, comptime T: type) *T { return self.is(T).?; } pub fn asNode(self: *Document) *Node { return self._proto; } pub fn asEventTarget(self: *Document) *@import("EventTarget.zig") { return self._proto.asEventTarget(); } pub fn getURL(self: *const Document, page: *const Page) [:0]const u8 { return self._url orelse page.url; } pub fn getContentType(self: *const Document) []const u8 { return switch (self._type) { .html => "text/html", .xml => "application/xml", .generic => "application/xml", }; } pub fn getDomain(_: *const Document, page: *const Page) []const u8 { return URL.getHostname(page.url); } const CreateElementOptions = struct { is: ?[]const u8 = null, }; pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element { try validateElementName(name); const ns: Element.Namespace, const normalized_name = blk: { if (self._type == .html) { break :blk .{ .html, std.ascii.lowerString(&page.buf, name) }; } // Generic and XML documents create elements with null namespace break :blk .{ .null, name }; }; // HTML documents are case-insensitive - lowercase the tag name const node = try page.createElementNS(ns, normalized_name, null); const element = node.as(Element); // Track owner document if it's not the main document if (self != page.document) { try page.setNodeOwnerDocument(node, self); } const options = options_ orelse return element; if (options.is) |is_value| { try element.setAttribute(comptime .wrap("is"), .wrap(is_value), page); try Element.Html.Custom.checkAndAttachBuiltIn(element, page); } return element; } pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8, page: *Page) !*Element { try validateElementName(name); const ns = Element.Namespace.parse(namespace); // Per spec, createElementNS does NOT lowercase (unlike createElement). const node = try page.createElementNS(ns, name, null); // Store original URI for unknown namespaces so lookupNamespaceURI can return it if (ns == .unknown) { if (namespace) |uri| { const duped = try page.dupeString(uri); try page._element_namespace_uris.put(page.arena, node.as(Element), duped); } } // Track owner document if it's not the main document if (self != page.document) { try page.setNodeOwnerDocument(node, self); } return node.as(Element); } pub fn createAttribute(_: *const Document, name: String.Global, page: *Page) !?*Element.Attribute { try Element.Attribute.validateAttributeName(name.str); return page._factory.node(Element.Attribute{ ._proto = undefined, ._name = name.str, ._value = String.empty, ._element = null, }); } pub fn createAttributeNS(_: *const Document, namespace: []const u8, name: String.Global, page: *Page) !?*Element.Attribute { if (std.mem.eql(u8, namespace, "http://www.w3.org/1999/xhtml") == false) { log.warn(.not_implemented, "document.createAttributeNS", .{ .namespace = namespace }); } try Element.Attribute.validateAttributeName(name.str); return page._factory.node(Element.Attribute{ ._proto = undefined, ._name = name.str, ._value = String.empty, ._element = null, }); } pub fn getElementById(self: *Document, id: []const u8, page: *Page) ?*Element { if (id.len == 0) { return null; } if (self._elements_by_id.get(id)) |element| { return element; } //ID was removed but might have duplicates if (self._removed_ids.remove(id)) { var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{}); while (tw.next()) |el| { const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse continue; if (std.mem.eql(u8, element_id, id)) { // we ignore this error to keep getElementById easy to call // if it really failed, then we're out of memory and nothing's // going to work like it should anyways. const owned_id = page.dupeString(id) catch return null; self._elements_by_id.put(page.arena, owned_id, el) catch return null; return el; } } } return null; } pub fn getElementsByTagName(self: *Document, tag_name: []const u8, page: *Page) !Node.GetElementsByTagNameResult { return self.asNode().getElementsByTagName(tag_name, page); } pub fn getElementsByTagNameNS(self: *Document, namespace: ?[]const u8, local_name: []const u8, page: *Page) !collections.NodeLive(.tag_name_ns) { return self.asNode().getElementsByTagNameNS(namespace, local_name, page); } pub fn getElementsByClassName(self: *Document, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) { return self.asNode().getElementsByClassName(class_name, page); } pub fn getElementsByName(self: *Document, name: []const u8, page: *Page) !collections.NodeLive(.name) { const arena = page.arena; const filter = try arena.dupe(u8, name); return collections.NodeLive(.name).init(self.asNode(), filter, page); } pub fn getChildren(self: *Document, page: *Page) !collections.NodeLive(.child_elements) { return collections.NodeLive(.child_elements).init(self.asNode(), {}, page); } pub fn getDocumentElement(self: *Document) ?*Element { var child = self.asNode().firstChild(); while (child) |node| { if (node.is(Element)) |el| { return el; } child = node.nextSibling(); } return null; } pub fn getSelection(self: *Document) *Selection { return &self._selection; } pub fn querySelector(self: *Document, input: String, page: *Page) !?*Element { return Selector.querySelector(self.asNode(), input.str(), page); } pub fn querySelectorAll(self: *Document, input: String, page: *Page) !*Selector.List { return Selector.querySelectorAll(self.asNode(), input.str(), page); } pub fn getImplementation(self: *Document, page: *Page) !*DOMImplementation { if (self._implementation) |impl| return impl; const impl = try page._factory.create(DOMImplementation{}); self._implementation = impl; return impl; } pub fn createDocumentFragment(self: *Document, page: *Page) !*Node.DocumentFragment { const frag = try Node.DocumentFragment.init(page); // Track owner document if it's not the main document if (self != page.document) { try page.setNodeOwnerDocument(frag.asNode(), self); } return frag; } pub fn createComment(self: *Document, data: []const u8, page: *Page) !*Node { const node = try page.createComment(data); // Track owner document if it's not the main document if (self != page.document) { try page.setNodeOwnerDocument(node, self); } return node; } pub fn createTextNode(self: *Document, data: []const u8, page: *Page) !*Node { const node = try page.createTextNode(data); // Track owner document if it's not the main document if (self != page.document) { try page.setNodeOwnerDocument(node, self); } return node; } pub fn createCDATASection(self: *Document, data: []const u8, page: *Page) !*Node { const node = switch (self._type) { .html => return error.NotSupported, // cannot create a CDataSection in an HTMLDocument .xml => try page.createCDATASection(data), .generic => try page.createCDATASection(data), }; // Track owner document if it's not the main document if (self != page.document) { try page.setNodeOwnerDocument(node, self); } return node; } pub fn createProcessingInstruction(self: *Document, target: []const u8, data: []const u8, page: *Page) !*Node { const node = try page.createProcessingInstruction(target, data); // Track owner document if it's not the main document if (self != page.document) { try page.setNodeOwnerDocument(node, self); } return node; } const Range = @import("Range.zig"); pub fn createRange(_: *const Document, page: *Page) !*Range { return Range.init(page); } pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@import("Event.zig") { const Event = @import("Event.zig"); if (event_type.len > 100) { return error.NotSupported; } const normalized = std.ascii.lowerString(&page.buf, event_type); if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents")) { return Event.init("", null, page); } if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) { const CustomEvent = @import("event/CustomEvent.zig"); return (try CustomEvent.init("", null, page)).asEvent(); } if (std.mem.eql(u8, normalized, "keyboardevent")) { const KeyboardEvent = @import("event/KeyboardEvent.zig"); return (try KeyboardEvent.init("", null, page)).asEvent(); } if (std.mem.eql(u8, normalized, "inputevent")) { const InputEvent = @import("event/InputEvent.zig"); return (try InputEvent.init("", null, page)).asEvent(); } if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) { const MouseEvent = @import("event/MouseEvent.zig"); return (try MouseEvent.init("", null, page)).asEvent(); } if (std.mem.eql(u8, normalized, "messageevent")) { const MessageEvent = @import("event/MessageEvent.zig"); return (try MessageEvent.init("", null, page)).asEvent(); } if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) { const UIEvent = @import("event/UIEvent.zig"); return (try UIEvent.init("", null, page)).asEvent(); } if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) { const FocusEvent = @import("event/FocusEvent.zig"); return (try FocusEvent.init("", null, page)).asEvent(); } if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) { const TextEvent = @import("event/TextEvent.zig"); return (try TextEvent.init("", null, page)).asEvent(); } return error.NotSupported; } pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMTreeWalker.FilterOpts, page: *Page) !*DOMTreeWalker { return DOMTreeWalker.init(root, try whatToShow(what_to_show), filter, page); } pub fn createNodeIterator(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMNodeIterator.FilterOpts, page: *Page) !*DOMNodeIterator { return DOMNodeIterator.init(root, try whatToShow(what_to_show), filter, page); } fn whatToShow(value_: ?js.Value) !u32 { const value = value_ orelse return 4294967295; // show all when undefined if (value.isUndefined()) { // undefined explicitly passed return 4294967295; } if (value.isNull()) { return 0; } return value.toZig(u32); } pub fn getReadyState(self: *const Document) []const u8 { return @tagName(self._ready_state); } pub fn getActiveElement(self: *Document) ?*Element { if (self._active_element) |el| { return el; } // Default to body if it exists if (self.is(HTMLDocument)) |html_doc| { if (html_doc.getBody()) |body| { return body.asElement(); } } // Fallback to document element return self.getDocumentElement(); } pub fn getStyleSheets(self: *Document, page: *Page) !*StyleSheetList { if (self._style_sheets) |sheets| { return sheets; } const sheets = try StyleSheetList.init(page); self._style_sheets = sheets; return sheets; } pub fn getFonts(self: *Document, page: *Page) !*FontFaceSet { if (self._fonts) |fonts| { return fonts; } const fonts = try FontFaceSet.init(page); self._fonts = fonts; return fonts; } pub fn adoptNode(_: *const Document, node: *Node, page: *Page) !*Node { if (node._type == .document) { return error.NotSupported; } if (node._parent) |parent| { page.removeNode(parent, node, .{ .will_be_reconnected = false }); } return node; } pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, page: *Page) !*Node { if (node._type == .document) { return error.NotSupported; } return node.cloneNode(deep_, page); } pub fn append(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { try validateDocumentNodes(self, nodes, false); page.domChanged(); const parent = self.asNode(); const parent_is_connected = parent.isConnected(); for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); // DocumentFragments are special - append all their children if (child.is(Node.DocumentFragment)) |_| { try page.appendAllChildren(child, parent); continue; } var child_connected = false; if (child._parent) |previous_parent| { child_connected = child.isConnected(); page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); } try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); } } pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { try validateDocumentNodes(self, nodes, false); page.domChanged(); const parent = self.asNode(); const parent_is_connected = parent.isConnected(); var i = nodes.len; while (i > 0) { i -= 1; const child = try nodes[i].toNode(page); // DocumentFragments are special - need to insert all their children if (child.is(Node.DocumentFragment)) |frag| { const first_child = parent.firstChild(); var frag_child = frag.asNode().lastChild(); while (frag_child) |fc| { const prev = fc.previousSibling(); page.removeNode(frag.asNode(), fc, .{ .will_be_reconnected = parent_is_connected }); if (first_child) |before| { try page.insertNodeRelative(parent, fc, .{ .before = before }, .{}); } else { try page.appendNode(parent, fc, .{}); } frag_child = prev; } continue; } var child_connected = false; if (child._parent) |previous_parent| { child_connected = child.isConnected(); page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); } const first_child = parent.firstChild(); if (first_child) |before| { try page.insertNodeRelative(parent, child, .{ .before = before }, .{ .child_already_connected = child_connected }); } else { try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); } } } pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { try validateDocumentNodes(self, nodes, true); page.domChanged(); const parent = self.asNode(); // Remove all existing children var it = parent.childrenIterator(); while (it.next()) |child| { page.removeNode(parent, child, .{ .will_be_reconnected = false }); } // Append new children const parent_is_connected = parent.isConnected(); for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); // DocumentFragments are special - append all their children if (child.is(Node.DocumentFragment)) |_| { try page.appendAllChildren(child, parent); continue; } var child_connected = false; if (child._parent) |previous_parent| { child_connected = child.isConnected(); page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); } try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); } } pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element { // Traverse document in depth-first order to find the topmost (last in document order) // element that contains the point (x, y) var topmost: ?*Element = null; const root = self.asNode(); var stack: std.ArrayList(*Node) = .empty; try stack.append(page.call_arena, root); while (stack.items.len > 0) { const node = stack.pop() orelse break; if (node.is(Element)) |element| { if (element.checkVisibility(page)) { const rect = element.getBoundingClientRectForVisible(page); if (x >= rect.getLeft() and x <= rect.getRight() and y >= rect.getTop() and y <= rect.getBottom()) { topmost = element; } } } // Add children to stack in reverse order so we process them in document order var child = node.lastChild(); while (child) |c| { try stack.append(page.call_arena, c); child = c.previousSibling(); } } return topmost; } pub fn elementsFromPoint(self: *Document, x: f64, y: f64, page: *Page) ![]const *Element { // Get topmost element var current: ?*Element = (try self.elementFromPoint(x, y, page)) orelse return &.{}; var result: std.ArrayList(*Element) = .empty; while (current) |el| { try result.append(page.call_arena, el); current = el.parentElement(); } return result.items; } pub fn getDocType(self: *Document) ?*Node { var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{}); while (tw.next()) |node| { if (node._type == .document_type) { return node; } } return null; } // document.write is complicated and works differently based on the state of // parsing. But, generally, it's supposed to be additive/streaming. Multiple // document.writes are parsed a single unit. Well, that causes issues with // html5ever if we're trying to parse 1 document which is really many. So we // try to detect "new" documents. (This is particularly problematic because we // don't have proper frame support, so document.write into a frame can get // sent to the main document (instead of the frame document)...and it's completely // reasonable for 2 frames to document.write("...") into their own // frame. fn looksLikeNewDocument(html: []const u8) bool { const trimmed = std.mem.trimLeft(u8, html, &std.ascii.whitespace); return std.ascii.startsWithIgnoreCase(trimmed, " 0) { return error.InvalidStateError; } const html = blk: { var joined: std.ArrayList(u8) = .empty; for (text) |str| { try joined.appendSlice(page.call_arena, str); } break :blk joined.items; }; if (self._current_script == null or page._load_state != .parsing) { if (self._script_created_parser == null or looksLikeNewDocument(html)) { _ = try self.open(page); } if (html.len > 0) { if (self._script_created_parser) |*parser| { parser.read(html) catch |err| { log.warn(.dom, "document.write parser error", .{ .err = err }); // was alrady closed self._script_created_parser = null; }; } } return; } // Inline script during parsing const script = self._current_script.?; const parent = script.asNode().parentNode() orelse return; // Our implemnetation is hacky. We'll write to a DocumentFragment, then // append its children. const fragment = try Node.DocumentFragment.init(page); const fragment_node = fragment.asNode(); const previous_parse_mode = page._parse_mode; page._parse_mode = .document_write; defer page._parse_mode = previous_parse_mode; const arena = try page.getArena(.{ .debug = "Document.write" }); defer page.releaseArena(arena); var parser = Parser.init(arena, fragment_node, page); parser.parseFragment(html); // Extract children from wrapper HTML element (html5ever wraps fragments) // https://github.com/servo/html5ever/issues/583 const children = fragment_node._children orelse return; const first = children.first(); // Collect all children to insert (to avoid iterator invalidation) var children_to_insert: std.ArrayList(*Node) = .empty; var it = if (first.is(Element.Html.Html) == null) fragment_node.childrenIterator() else first.childrenIterator(); while (it.next()) |child| { try children_to_insert.append(arena, child); } if (children_to_insert.items.len == 0) { return; } // Determine insertion point: // - If _write_insertion_point is set, continue from there (subsequent write) // - Otherwise, start after the script (first write) var insert_after: ?*Node = self._write_insertion_point orelse script.asNode(); for (children_to_insert.items) |child| { // Clear parent pointer (child is currently parented to fragment/HTML wrapper) child._parent = null; try page.insertNodeRelative(parent, child, .{ .after = insert_after.? }, .{}); insert_after = child; } page.domChanged(); self._write_insertion_point = children_to_insert.getLast(); } pub fn open(self: *Document, page: *Page) !*Document { if (self._type == .xml) { return error.InvalidStateError; } if (self._throw_on_dynamic_markup_insertion_counter > 0) { return error.InvalidStateError; } if (page._load_state == .parsing) { return self; } if (self._script_created_parser != null) { return self; } // If we aren't parsing, then open clears the document. const doc_node = self.asNode(); { // Remove all children from document var it = doc_node.childrenIterator(); while (it.next()) |child| { page.removeNode(doc_node, child, .{ .will_be_reconnected = false }); } } // reset the document self._elements_by_id.clearAndFree(page.arena); self._active_element = null; self._style_sheets = null; self._implementation = null; self._ready_state = .loading; self._script_created_parser = Parser.Streaming.init(page.arena, doc_node, page); try self._script_created_parser.?.start(); page._parse_mode = .document; return self; } pub fn close(self: *Document, page: *Page) !void { if (self._type == .xml) { return error.InvalidStateError; } if (self._throw_on_dynamic_markup_insertion_counter > 0) { return error.InvalidStateError; } if (self._script_created_parser == null) { return; } // done() calls html5ever_streaming_parser_finish which frees the parser // We must NOT call deinit() after done() as that would be a double-free self._script_created_parser.?.done(); // Just null out the handle since done() already freed it self._script_created_parser.?.handle = null; self._script_created_parser = null; page.documentIsComplete(); } pub fn getFirstElementChild(self: *Document) ?*Element { var it = self.asNode().childrenIterator(); while (it.next()) |child| { if (child.is(Element)) |el| { return el; } } return null; } pub fn getLastElementChild(self: *Document) ?*Element { var maybe_child = self.asNode().lastChild(); while (maybe_child) |child| { if (child.is(Element)) |el| { return el; } maybe_child = child.previousSibling(); } return null; } pub fn getChildElementCount(self: *Document) u32 { var i: u32 = 0; var it = self.asNode().childrenIterator(); while (it.next()) |child| { if (child.is(Element) != null) { i += 1; } } return i; } pub fn getAdoptedStyleSheets(self: *Document, page: *Page) !js.Object.Global { if (self._adopted_style_sheets) |ass| { return ass; } const js_arr = page.js.local.?.newArray(0); const js_obj = js_arr.toObject(); self._adopted_style_sheets = try js_obj.persist(); return self._adopted_style_sheets.?; } pub fn hasFocus(_: *Document) bool { log.debug(.not_implemented, "Document.hasFocus", .{}); return true; } pub fn setAdoptedStyleSheets(self: *Document, sheets: js.Object) !void { self._adopted_style_sheets = try sheets.persist(); } // Validates that nodes can be inserted into a Document, respecting Document constraints: // - At most one Element child // - At most one DocumentType child // - No Document, Attribute, or Text nodes // - Only Element, DocumentType, Comment, and ProcessingInstruction are allowed // When replacing=true, existing children are not counted (for replaceChildren) fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, comptime replacing: bool) !void { const parent = self.asNode(); // Check existing elements and doctypes (unless we're replacing all children) var has_element = false; var has_doctype = false; if (!replacing) { var it = parent.childrenIterator(); while (it.next()) |child| { if (child._type == .element) { has_element = true; } else if (child._type == .document_type) { has_doctype = true; } } } // Validate new nodes for (nodes) |node_or_text| { switch (node_or_text) { .text => { // Text nodes are not allowed as direct children of Document return error.HierarchyError; }, .node => |child| { // Check if it's a DocumentFragment - need to validate its children if (child.is(Node.DocumentFragment)) |frag| { var frag_it = frag.asNode().childrenIterator(); while (frag_it.next()) |frag_child| { // Document can only contain: Element, DocumentType, Comment, ProcessingInstruction switch (frag_child._type) { .element => { if (has_element) { return error.HierarchyError; } has_element = true; }, .document_type => { if (has_doctype) { return error.HierarchyError; } has_doctype = true; }, .cdata => |cd| switch (cd._type) { .comment, .processing_instruction => {}, // Allowed .text, .cdata_section => return error.HierarchyError, // Not allowed in Document }, .document, .attribute, .document_fragment => return error.HierarchyError, } } } else { // Validate node type for direct insertion switch (child._type) { .element => { if (has_element) { return error.HierarchyError; } has_element = true; }, .document_type => { if (has_doctype) { return error.HierarchyError; } has_doctype = true; }, .cdata => |cd| switch (cd._type) { .comment, .processing_instruction => {}, // Allowed .text, .cdata_section => return error.HierarchyError, // Not allowed in Document }, .document, .attribute, .document_fragment => return error.HierarchyError, } } // Check for cycles if (child.contains(parent)) { return error.HierarchyError; } }, } } } fn validateElementName(name: []const u8) !void { if (name.len == 0) { return error.InvalidCharacterError; } const first = name[0]; // Element names cannot start with: digits, period, hyphen if ((first >= '0' and first <= '9') or first == '.' or first == '-') { return error.InvalidCharacterError; } for (name[1..]) |c| { const is_valid = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '_' or c == '-' or c == '.' or c == ':' or c >= 128; // Allow non-ASCII UTF-8 if (!is_valid) { return error.InvalidCharacterError; } } } // When a page or frame's URL is about:blank, or as soon as a frame is // programmatically created, it has this default "blank" content pub fn injectBlank(self: *Document, page: *Page) error{InjectBlankError}!void { self._injectBlank(page) catch |err| { // we wrap _injectBlank like this so that injectBlank can only return an // InjectBlankError. injectBlank is used in when nodes are inserted // as since it inserts node itself, Zig can't infer the error set. log.err(.browser, "inject blank", .{ .err = err }); return error.InjectBlankError; }; } fn _injectBlank(self: *Document, page: *Page) !void { if (comptime IS_DEBUG) { // should only be called on an empty document std.debug.assert(self.asNode()._children == null); } const html = try page.createElementNS(.html, "html", null); const head = try page.createElementNS(.html, "head", null); const body = try page.createElementNS(.html, "body", null); try page.appendNode(html, head, .{}); try page.appendNode(html, body, .{}); try page.appendNode(self.asNode(), html, .{}); } const ReadyState = enum { loading, interactive, complete, }; pub const JsApi = struct { pub const bridge = js.Bridge(Document); pub const Meta = struct { pub const name = "Document"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const constructor = bridge.constructor(_constructor, .{}); fn _constructor(page: *Page) !*Document { return page._factory.node(Document{ ._proto = undefined, ._type = .generic, }); } pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{}); pub const URL = bridge.accessor(Document.getURL, null, .{}); pub const documentURI = bridge.accessor(Document.getURL, null, .{}); pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{}); pub const scrollingElement = bridge.accessor(Document.getDocumentElement, null, .{}); pub const children = bridge.accessor(Document.getChildren, null, .{}); pub const readyState = bridge.accessor(Document.getReadyState, null, .{}); pub const implementation = bridge.accessor(Document.getImplementation, null, .{}); pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{}); pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{}); pub const fonts = bridge.accessor(Document.getFonts, null, .{}); pub const contentType = bridge.accessor(Document.getContentType, null, .{}); pub const domain = bridge.accessor(Document.getDomain, null, .{}); pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true }); pub const createElementNS = bridge.function(Document.createElementNS, .{ .dom_exception = true }); pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); pub const createComment = bridge.function(Document.createComment, .{}); pub const createTextNode = bridge.function(Document.createTextNode, .{}); pub const createAttribute = bridge.function(Document.createAttribute, .{ .dom_exception = true }); pub const createAttributeNS = bridge.function(Document.createAttributeNS, .{ .dom_exception = true }); pub const createCDATASection = bridge.function(Document.createCDATASection, .{ .dom_exception = true }); pub const createProcessingInstruction = bridge.function(Document.createProcessingInstruction, .{ .dom_exception = true }); pub const createRange = bridge.function(Document.createRange, .{}); pub const createEvent = bridge.function(Document.createEvent, .{ .dom_exception = true }); pub const createTreeWalker = bridge.function(Document.createTreeWalker, .{}); pub const createNodeIterator = bridge.function(Document.createNodeIterator, .{}); pub const getElementById = bridge.function(_getElementById, .{}); fn _getElementById(self: *Document, value_: ?js.Value, page: *Page) !?*Element { const value = value_ orelse return null; if (value.isNull()) { return self.getElementById("null", page); } if (value.isUndefined()) { return self.getElementById("undefined", page); } return self.getElementById(try value.toZig([]const u8), page); } pub const querySelector = bridge.function(Document.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true }); pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{}); pub const getElementsByTagNameNS = bridge.function(Document.getElementsByTagNameNS, .{}); pub const getSelection = bridge.function(Document.getSelection, .{}); pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{}); pub const getElementsByName = bridge.function(Document.getElementsByName, .{}); pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true }); pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true }); pub const append = bridge.function(Document.append, .{ .dom_exception = true }); pub const prepend = bridge.function(Document.prepend, .{ .dom_exception = true }); pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .dom_exception = true }); pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{}); pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{}); pub const write = bridge.function(Document.write, .{ .dom_exception = true }); pub const open = bridge.function(Document.open, .{ .dom_exception = true }); pub const close = bridge.function(Document.close, .{ .dom_exception = true }); pub const doctype = bridge.accessor(Document.getDocType, null, .{}); pub const firstElementChild = bridge.accessor(Document.getFirstElementChild, null, .{}); pub const lastElementChild = bridge.accessor(Document.getLastElementChild, null, .{}); pub const childElementCount = bridge.accessor(Document.getChildElementCount, null, .{}); pub const adoptedStyleSheets = bridge.accessor(Document.getAdoptedStyleSheets, Document.setAdoptedStyleSheets, .{}); pub const hidden = bridge.property(false, .{ .template = false, .readonly = true }); pub const visibilityState = bridge.property("visible", .{ .template = false, .readonly = true }); pub const defaultView = bridge.accessor(struct { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { return page.window; } }.defaultView, null, .{}); pub const hasFocus = bridge.function(Document.hasFocus, .{}); pub const prerendering = bridge.property(false, .{ .template = false }); pub const characterSet = bridge.property("UTF-8", .{ .template = false }); pub const charset = bridge.property("UTF-8", .{ .template = false }); pub const inputEncoding = bridge.property("UTF-8", .{ .template = false }); pub const compatMode = bridge.property("CSS1Compat", .{ .template = false }); pub const referrer = bridge.property("", .{ .template = false }); }; const testing = @import("../../testing.zig"); test "WebApi: Document" { try testing.htmlRunner("document", .{}); } ================================================ FILE: src/browser/webapi/DocumentFragment.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); const ShadowRoot = @import("ShadowRoot.zig"); const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); const DocumentFragment = @This(); _type: Type, _proto: *Node, pub const Type = union(enum) { generic, shadow_root: *ShadowRoot, }; pub fn is(self: *DocumentFragment, comptime T: type) ?*T { switch (self._type) { .shadow_root => |shadow_root| { if (T == ShadowRoot) { return shadow_root; } }, .generic => {}, } return null; } pub fn as(self: *DocumentFragment, comptime T: type) *T { return self.is(T).?; } pub fn init(page: *Page) !*DocumentFragment { return page._factory.node(DocumentFragment{ ._type = .generic, ._proto = undefined, }); } pub fn asNode(self: *DocumentFragment) *Node { return self._proto; } pub fn asEventTarget(self: *DocumentFragment) *@import("EventTarget.zig") { return self._proto.asEventTarget(); } pub fn getElementById(self: *DocumentFragment, id: []const u8) ?*Element { if (id.len == 0) { return null; } var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{}); while (tw.next()) |el| { if (el.getAttributeSafe(comptime .wrap("id"))) |element_id| { if (std.mem.eql(u8, element_id, id)) { return el; } } } return null; } pub fn querySelector(self: *DocumentFragment, selector: []const u8, page: *Page) !?*Element { return Selector.querySelector(self.asNode(), selector, page); } pub fn querySelectorAll(self: *DocumentFragment, input: []const u8, page: *Page) !*Selector.List { return Selector.querySelectorAll(self.asNode(), input, page); } pub fn getChildren(self: *DocumentFragment, page: *Page) !collections.NodeLive(.child_elements) { return collections.NodeLive(.child_elements).init(self.asNode(), {}, page); } pub fn firstElementChild(self: *DocumentFragment) ?*Element { var maybe_child = self.asNode().firstChild(); while (maybe_child) |child| { if (child.is(Element)) |el| return el; maybe_child = child.nextSibling(); } return null; } pub fn lastElementChild(self: *DocumentFragment) ?*Element { var maybe_child = self.asNode().lastChild(); while (maybe_child) |child| { if (child.is(Element)) |el| return el; maybe_child = child.previousSibling(); } return null; } pub fn getChildElementCount(self: *DocumentFragment) usize { var count: usize = 0; var it = self.asNode().childrenIterator(); while (it.next()) |node| { if (node.is(Element) != null) { count += 1; } } return count; } pub fn append(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void { const parent = self.asNode(); for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); _ = try parent.appendChild(child, page); } } pub fn prepend(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void { const parent = self.asNode(); var i = nodes.len; while (i > 0) { i -= 1; const child = try nodes[i].toNode(page); _ = try parent.insertBefore(child, parent.firstChild(), page); } } pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void { page.domChanged(); var parent = self.asNode(); var it = parent.childrenIterator(); while (it.next()) |child| { page.removeNode(parent, child, .{ .will_be_reconnected = false }); } const parent_is_connected = parent.isConnected(); for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); // If the new children has already a parent, remove from it. if (child._parent) |p| { page.removeNode(p, child, .{ .will_be_reconnected = true }); } try page.appendNode(parent, child, .{ .child_already_connected = parent_is_connected }); } } pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); return dump.children(self.asNode(), .{ .shadow = .complete }, writer, page); } pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, page: *Page) !void { const parent = self.asNode(); page.domChanged(); var it = parent.childrenIterator(); while (it.next()) |child| { page.removeNode(parent, child, .{ .will_be_reconnected = false }); } if (html.len == 0) { return; } try page.parseHtmlAsChildren(parent, html); } pub fn cloneFragment(self: *DocumentFragment, deep: bool, page: *Page) !*Node { const fragment = try DocumentFragment.init(page); const fragment_node = fragment.asNode(); if (deep) { const node = self.asNode(); const self_is_connected = node.isConnected(); var child_it = node.childrenIterator(); while (child_it.next()) |child| { if (try child.cloneNodeForAppending(true, page)) |cloned_child| { try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected }); } } } return fragment_node; } pub fn isEqualNode(self: *DocumentFragment, other: *DocumentFragment) bool { var self_iter = self.asNode().childrenIterator(); var other_iter = other.asNode().childrenIterator(); while (true) { const self_child = self_iter.next(); const other_child = other_iter.next(); if ((self_child == null) != (other_child == null)) { return false; } if (self_child == null) { // We've reached the end return true; } if (!self_child.?.isEqualNode(other_child.?)) { return false; } } } pub const JsApi = struct { pub const bridge = js.Bridge(DocumentFragment); pub const Meta = struct { pub const name = "DocumentFragment"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const constructor = bridge.constructor(DocumentFragment.init, .{}); pub const getElementById = bridge.function(_getElementById, .{}); fn _getElementById(self: *DocumentFragment, value_: ?js.Value) !?*Element { const value = value_ orelse return null; if (value.isNull()) { return self.getElementById("null"); } if (value.isUndefined()) { return self.getElementById("undefined"); } return self.getElementById(try value.toZig([]const u8)); } pub const querySelector = bridge.function(DocumentFragment.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(DocumentFragment.querySelectorAll, .{ .dom_exception = true }); pub const children = bridge.accessor(DocumentFragment.getChildren, null, .{}); pub const childElementCount = bridge.accessor(DocumentFragment.getChildElementCount, null, .{}); pub const firstElementChild = bridge.accessor(DocumentFragment.firstElementChild, null, .{}); pub const lastElementChild = bridge.accessor(DocumentFragment.lastElementChild, null, .{}); pub const append = bridge.function(DocumentFragment.append, .{ .dom_exception = true }); pub const prepend = bridge.function(DocumentFragment.prepend, .{ .dom_exception = true }); pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{ .dom_exception = true }); pub const innerHTML = bridge.accessor(_innerHTML, DocumentFragment.setInnerHTML, .{}); fn _innerHTML(self: *DocumentFragment, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getInnerHTML(&buf.writer, page); return buf.written(); } }; const testing = @import("../../testing.zig"); test "WebApi: DocumentFragment" { try testing.htmlRunner("document_fragment", .{}); } ================================================ FILE: src/browser/webapi/DocumentType.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const DocumentType = @This(); _proto: *Node, _name: []const u8, _public_id: []const u8, _system_id: []const u8, pub fn init(qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, page: *Page) !*DocumentType { const name = try page.dupeString(qualified_name); // Firefox converts null to the string "null", not empty string const pub_id = if (public_id) |p| try page.dupeString(p) else "null"; const sys_id = if (system_id) |s| try page.dupeString(s) else "null"; return page._factory.node(DocumentType{ ._proto = undefined, ._name = name, ._public_id = pub_id, ._system_id = sys_id, }); } pub fn asNode(self: *DocumentType) *Node { return self._proto; } pub fn asEventTarget(self: *DocumentType) *@import("EventTarget.zig") { return self._proto.asEventTarget(); } pub fn getName(self: *const DocumentType) []const u8 { return self._name; } pub fn getPublicId(self: *const DocumentType) []const u8 { return self._public_id; } pub fn getSystemId(self: *const DocumentType) []const u8 { return self._system_id; } pub fn isEqualNode(self: *const DocumentType, other: *const DocumentType) bool { return std.mem.eql(u8, self._name, other._name) and std.mem.eql(u8, self._public_id, other._public_id) and std.mem.eql(u8, self._system_id, other._system_id); } pub fn clone(self: *const DocumentType, page: *Page) !*DocumentType { return .init(self._name, self._public_id, self._system_id, page); } pub fn remove(self: *DocumentType, page: *Page) !void { const node = self.asNode(); const parent = node.parentNode() orelse return; _ = try parent.removeChild(node, page); } pub const JsApi = struct { pub const bridge = js.Bridge(DocumentType); pub const Meta = struct { pub const name = "DocumentType"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const name = bridge.accessor(DocumentType.getName, null, .{}); pub const publicId = bridge.accessor(DocumentType.getPublicId, null, .{}); pub const systemId = bridge.accessor(DocumentType.getSystemId, null, .{}); pub const remove = bridge.function(DocumentType.remove, .{}); }; ================================================ FILE: src/browser/webapi/Element.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const lp = @import("lightpanda"); const log = @import("../../log.zig"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const reflect = @import("../reflect.zig"); const Node = @import("Node.zig"); const CSS = @import("CSS.zig"); const ShadowRoot = @import("ShadowRoot.zig"); const EventTarget = @import("EventTarget.zig"); const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); const Animation = @import("animation/Animation.zig"); const DOMStringMap = @import("element/DOMStringMap.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); pub const DOMRect = @import("DOMRect.zig"); pub const Svg = @import("element/Svg.zig"); pub const Html = @import("element/Html.zig"); pub const Attribute = @import("element/Attribute.zig"); const Element = @This(); pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap); pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties); pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot); pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Element, *Html.Slot); pub const NamespaceUriLookup = std.AutoHashMapUnmanaged(*Element, []const u8); pub const ScrollPosition = struct { x: u32 = 0, y: u32 = 0, }; pub const ScrollPositionLookup = std.AutoHashMapUnmanaged(*Element, ScrollPosition); pub const Namespace = enum(u8) { html, svg, mathml, xml, // We should keep the original value, but don't. If this becomes important // consider storing it in a page lookup, like `_element_class_lists`, rather // that adding a slice directly here (directly in every element). unknown, null, pub fn toUri(self: Namespace) ?[]const u8 { return switch (self) { .html => "http://www.w3.org/1999/xhtml", .svg => "http://www.w3.org/2000/svg", .mathml => "http://www.w3.org/1998/Math/MathML", .xml => "http://www.w3.org/XML/1998/namespace", .unknown => "http://lightpanda.io/unsupported/namespace", .null => null, }; } pub fn parse(namespace_: ?[]const u8) Namespace { const namespace = namespace_ orelse return .null; if (namespace.len == "http://www.w3.org/1999/xhtml".len) { // Common case, avoid the string comparion. Recklessly @branchHint(.likely); return .html; } if (std.mem.eql(u8, namespace, "http://www.w3.org/XML/1998/namespace")) { return .xml; } if (std.mem.eql(u8, namespace, "http://www.w3.org/2000/svg")) { return .svg; } if (std.mem.eql(u8, namespace, "http://www.w3.org/1998/Math/MathML")) { return .mathml; } return .unknown; } }; _type: Type, _proto: *Node, _namespace: Namespace = .html, _attributes: ?*Attribute.List = null, pub const Type = union(enum) { html: *Html, svg: *Svg, }; pub fn is(self: *Element, comptime T: type) ?*T { const type_name = @typeName(T); switch (self._type) { .html => |el| { if (T == Html) { return el; } if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.")) { return el.is(T); } }, .svg => |svg| { if (T == Svg) { return svg; } if (comptime std.mem.startsWith(u8, type_name, "webapi.element.svg.")) { return svg.is(T); } }, } return null; } pub fn as(self: *Element, comptime T: type) *T { return self.is(T).?; } pub fn asNode(self: *Element) *Node { return self._proto; } pub fn asEventTarget(self: *Element) *EventTarget { return self._proto.asEventTarget(); } pub fn asConstNode(self: *const Element) *const Node { return self._proto; } pub fn attributesEql(self: *const Element, other: *Element) bool { if (self._attributes) |attr_list| { const other_list = other._attributes orelse return false; return attr_list.eql(other_list); } // Make sure no attrs in both sides. return other._attributes == null; } /// TODO: localName and prefix comparison. pub fn isEqualNode(self: *Element, other: *Element) bool { const self_tag = self.getTagNameDump(); const other_tag = other.getTagNameDump(); // Compare namespaces and tags. const dirty = self._namespace != other._namespace or !std.mem.eql(u8, self_tag, other_tag); if (dirty) { return false; } // Compare attributes. if (!self.attributesEql(other)) { return false; } // Compare children. var self_iter = self.asNode().childrenIterator(); var other_iter = other.asNode().childrenIterator(); var self_count: usize = 0; var other_count: usize = 0; while (self_iter.next()) |self_node| : (self_count += 1) { const other_node = other_iter.next() orelse return false; other_count += 1; if (self_node.isEqualNode(other_node)) { continue; } return false; } // Make sure both have equal number of children. return self_count == other_count; } pub fn getTagNameLower(self: *const Element) []const u8 { switch (self._type) { .html => |he| switch (he._type) { .custom => |ce| { @branchHint(.unlikely); return ce._tag_name.str(); }, else => return switch (he._type) { .anchor => "a", .area => "area", .base => "base", .body => "body", .br => "br", .button => "button", .canvas => "canvas", .custom => |e| e._tag_name.str(), .data => "data", .datalist => "datalist", .details => "details", .dialog => "dialog", .directory => "dir", .div => "div", .dl => "dl", .embed => "embed", .fieldset => "fieldset", .font => "font", .form => "form", .generic => |e| e._tag_name.str(), .heading => |e| e._tag_name.str(), .head => "head", .html => "html", .hr => "hr", .iframe => "iframe", .img => "img", .input => "input", .label => "label", .legend => "legend", .li => "li", .link => "link", .map => "map", .media => |m| switch (m._type) { .audio => "audio", .video => "video", .generic => "media", }, .meta => "meta", .meter => "meter", .mod => |e| e._tag_name.str(), .object => "object", .ol => "ol", .optgroup => "optgroup", .option => "option", .output => "output", .p => "p", .picture => "picture", .param => "param", .pre => "pre", .progress => "progress", .quote => |e| e._tag_name.str(), .script => "script", .select => "select", .slot => "slot", .source => "source", .span => "span", .style => "style", .table => "table", .table_caption => "caption", .table_cell => |e| e._tag_name.str(), .table_col => |e| e._tag_name.str(), .table_row => "tr", .table_section => |e| e._tag_name.str(), .template => "template", .textarea => "textarea", .time => "time", .title => "title", .track => "track", .ul => "ul", .unknown => |e| e._tag_name.str(), }, }, .svg => |svg| return svg._tag_name.str(), } } pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { return switch (self._type) { .html => |he| switch (he._type) { .anchor => "A", .area => "AREA", .base => "BASE", .body => "BODY", .br => "BR", .button => "BUTTON", .canvas => "CANVAS", .custom => |e| upperTagName(&e._tag_name, buf), .data => "DATA", .datalist => "DATALIST", .details => "DETAILS", .dialog => "DIALOG", .directory => "DIR", .div => "DIV", .dl => "DL", .embed => "EMBED", .fieldset => "FIELDSET", .font => "FONT", .form => "FORM", .generic => |e| upperTagName(&e._tag_name, buf), .heading => |e| upperTagName(&e._tag_name, buf), .head => "HEAD", .html => "HTML", .hr => "HR", .iframe => "IFRAME", .img => "IMG", .input => "INPUT", .label => "LABEL", .legend => "LEGEND", .li => "LI", .link => "LINK", .map => "MAP", .meta => "META", .media => |m| switch (m._type) { .audio => "AUDIO", .video => "VIDEO", .generic => "MEDIA", }, .meter => "METER", .mod => |e| upperTagName(&e._tag_name, buf), .object => "OBJECT", .ol => "OL", .optgroup => "OPTGROUP", .option => "OPTION", .output => "OUTPUT", .p => "P", .picture => "PICTURE", .param => "PARAM", .pre => "PRE", .progress => "PROGRESS", .quote => |e| upperTagName(&e._tag_name, buf), .script => "SCRIPT", .select => "SELECT", .slot => "SLOT", .source => "SOURCE", .span => "SPAN", .style => "STYLE", .table => "TABLE", .table_caption => "CAPTION", .table_cell => |e| upperTagName(&e._tag_name, buf), .table_col => |e| upperTagName(&e._tag_name, buf), .table_row => "TR", .table_section => |e| upperTagName(&e._tag_name, buf), .template => "TEMPLATE", .textarea => "TEXTAREA", .time => "TIME", .title => "TITLE", .track => "TRACK", .ul => "UL", .unknown => |e| switch (self._namespace) { .html => upperTagName(&e._tag_name, buf), .svg, .xml, .mathml, .unknown, .null => e._tag_name.str(), }, }, .svg => |svg| svg._tag_name.str(), }; } pub fn getTagNameDump(self: *const Element) []const u8 { switch (self._type) { .html => return self.getTagNameLower(), .svg => |svg| return svg._tag_name.str(), } } pub fn getNamespaceURI(self: *const Element) ?[]const u8 { return self._namespace.toUri(); } pub fn getNamespaceUri(self: *Element, page: *Page) ?[]const u8 { if (self._namespace != .unknown) return self._namespace.toUri(); return page._element_namespace_uris.get(self); } pub fn lookupNamespaceURIForElement(self: *Element, prefix: ?[]const u8, page: *Page) ?[]const u8 { // Hardcoded reserved prefixes if (prefix) |p| { if (std.mem.eql(u8, p, "xml")) return "http://www.w3.org/XML/1998/namespace"; if (std.mem.eql(u8, p, "xmlns")) return "http://www.w3.org/2000/xmlns/"; } // Step 1: check element's own namespace/prefix if (self.getNamespaceUri(page)) |ns_uri| { const el_prefix = self._prefix(); const match = if (prefix == null and el_prefix == null) true else if (prefix != null and el_prefix != null) std.mem.eql(u8, prefix.?, el_prefix.?) else false; if (match) return ns_uri; } // Step 2: search xmlns attributes if (self._attributes) |attrs| { var iter = attrs.iterator(); while (iter.next()) |entry| { if (prefix == null) { if (entry._name.eql(comptime .wrap("xmlns"))) { const val = entry._value.str(); return if (val.len == 0) null else val; } } else { const name = entry._name.str(); if (std.mem.startsWith(u8, name, "xmlns:")) { if (std.mem.eql(u8, name["xmlns:".len..], prefix.?)) { const val = entry._value.str(); return if (val.len == 0) null else val; } } } } } // Step 3: recurse to parent element const parent = self.asNode().parentElement() orelse return null; return parent.lookupNamespaceURIForElement(prefix, page); } fn _prefix(self: *const Element) ?[]const u8 { const name = self.getTagNameLower(); if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| { return name[0..pos]; } return null; } pub fn getLocalName(self: *Element) []const u8 { const name = self.getTagNameLower(); if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| { return name[pos + 1 ..]; } return name; } // Wrapper methods that delegate to Html implementations pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void { const he = self.is(Html) orelse return error.NotHtmlElement; return he.getInnerText(writer); } pub fn setInnerText(self: *Element, text: []const u8, page: *Page) !void { const he = self.is(Html) orelse return error.NotHtmlElement; return he.setInnerText(text, page); } pub fn insertAdjacentHTML( self: *Element, position: []const u8, html_or_xml: []const u8, page: *Page, ) !void { const he = self.is(Html) orelse return error.NotHtmlElement; return he.insertAdjacentHTML(position, html_or_xml, page); } pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, page); } pub fn setOuterHTML(self: *Element, html: []const u8, page: *Page) !void { const node = self.asNode(); const parent = node._parent orelse return; page.domChanged(); if (html.len > 0) { const fragment = (try Node.DocumentFragment.init(page)).asNode(); try page.parseHtmlAsChildren(fragment, html); try page.insertAllChildrenBefore(fragment, parent, node); } page.removeNode(parent, node, .{ .will_be_reconnected = false }); } pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); return dump.children(self.asNode(), .{ .shadow = .skip }, writer, page); } pub fn setInnerHTML(self: *Element, html: []const u8, page: *Page) !void { const parent = self.asNode(); // Remove all existing children page.domChanged(); var it = parent.childrenIterator(); while (it.next()) |child| { page.removeNode(parent, child, .{ .will_be_reconnected = false }); } // Fast path: skip parsing if html is empty if (html.len == 0) { return; } // Parse and add new children try page.parseHtmlAsChildren(parent, html); } pub fn getId(self: *const Element) []const u8 { return self.getAttributeSafe(comptime .wrap("id")) orelse ""; } pub fn setId(self: *Element, value: []const u8, page: *Page) !void { return self.setAttributeSafe(comptime .wrap("id"), .wrap(value), page); } pub fn getSlot(self: *const Element) []const u8 { return self.getAttributeSafe(comptime .wrap("slot")) orelse ""; } pub fn setSlot(self: *Element, value: []const u8, page: *Page) !void { return self.setAttributeSafe(comptime .wrap("slot"), .wrap(value), page); } pub fn getDir(self: *const Element) []const u8 { return self.getAttributeSafe(comptime .wrap("dir")) orelse ""; } pub fn setDir(self: *Element, value: []const u8, page: *Page) !void { return self.setAttributeSafe(comptime .wrap("dir"), .wrap(value), page); } pub fn getClassName(self: *const Element) []const u8 { return self.getAttributeSafe(comptime .wrap("class")) orelse ""; } pub fn setClassName(self: *Element, value: []const u8, page: *Page) !void { return self.setAttributeSafe(comptime .wrap("class"), .wrap(value), page); } pub fn attributeIterator(self: *Element) Attribute.InnerIterator { const attributes = self._attributes orelse return .{}; return attributes.iterator(); } pub fn getAttribute(self: *const Element, name: String, page: *Page) !?String { const attributes = self._attributes orelse return null; return attributes.get(name, page); } /// For simplicity, the namespace is currently ignored and only the local name is used. pub fn getAttributeNS( self: *const Element, maybe_namespace: ?[]const u8, local_name: String, page: *Page, ) !?String { if (maybe_namespace) |namespace| { if (!std.mem.eql(u8, namespace, "http://www.w3.org/1999/xhtml")) { log.warn(.not_implemented, "Element.getAttributeNS", .{ .namespace = namespace }); } } return self.getAttribute(local_name, page); } pub fn getAttributeSafe(self: *const Element, name: String) ?[]const u8 { const attributes = self._attributes orelse return null; return attributes.getSafe(name); } pub fn hasAttribute(self: *const Element, name: String, page: *Page) !bool { const attributes = self._attributes orelse return false; const value = try attributes.get(name, page); return value != null; } pub fn hasAttributeSafe(self: *const Element, name: String) bool { const attributes = self._attributes orelse return false; return attributes.hasSafe(name); } pub fn hasAttributes(self: *const Element) bool { const attributes = self._attributes orelse return false; return attributes.isEmpty() == false; } pub fn getAttributeNode(self: *Element, name: String, page: *Page) !?*Attribute { const attributes = self._attributes orelse return null; return attributes.getAttribute(name, self, page); } pub fn setAttribute(self: *Element, name: String, value: String, page: *Page) !void { try Attribute.validateAttributeName(name); const attributes = try self.getOrCreateAttributeList(page); _ = try attributes.put(name, value, self, page); } pub fn setAttributeNS( self: *Element, maybe_namespace: ?[]const u8, qualified_name: []const u8, value: String, page: *Page, ) !void { const attr_name = if (maybe_namespace) |namespace| blk: { // For xmlns namespace, store the full qualified name (e.g. "xmlns:bar") // so lookupNamespaceURI can find namespace declarations. if (std.mem.eql(u8, namespace, "http://www.w3.org/2000/xmlns/")) { break :blk qualified_name; } if (!std.mem.eql(u8, namespace, "http://www.w3.org/1999/xhtml")) { log.warn(.not_implemented, "Element.setAttributeNS", .{ .namespace = namespace }); } break :blk if (std.mem.indexOfScalarPos(u8, qualified_name, 0, ':')) |idx| qualified_name[idx + 1 ..] else qualified_name; } else blk: { break :blk if (std.mem.indexOfScalarPos(u8, qualified_name, 0, ':')) |idx| qualified_name[idx + 1 ..] else qualified_name; }; return self.setAttribute(.wrap(attr_name), value, page); } pub fn setAttributeSafe(self: *Element, name: String, value: String, page: *Page) !void { const attributes = try self.getOrCreateAttributeList(page); _ = try attributes.putSafe(name, value, self, page); } pub fn getOrCreateAttributeList(self: *Element, page: *Page) !*Attribute.List { return self._attributes orelse return self.createAttributeList(page); } pub fn createAttributeList(self: *Element, page: *Page) !*Attribute.List { lp.assert(self._attributes == null, "Element.createAttributeList non-null _attributes", .{}); const a = try page.arena.create(Attribute.List); a.* = .{ .normalize = self._namespace == .html }; self._attributes = a; return a; } pub fn getShadowRoot(self: *Element, page: *Page) ?*ShadowRoot { const shadow_root = page._element_shadow_roots.get(self) orelse return null; if (shadow_root._mode == .closed) return null; return shadow_root; } pub fn getAssignedSlot(self: *Element, page: *Page) ?*Html.Slot { return page._element_assigned_slots.get(self); } pub fn attachShadow(self: *Element, mode_str: []const u8, page: *Page) !*ShadowRoot { if (page._element_shadow_roots.get(self)) |_| { return error.AlreadyHasShadowRoot; } const mode = try ShadowRoot.Mode.fromString(mode_str); const shadow_root = try ShadowRoot.init(self, mode, page); try page._element_shadow_roots.put(page.arena, self, shadow_root); return shadow_root; } pub fn insertAdjacentElement( self: *Element, position: []const u8, element: *Element, page: *Page, ) !void { const target_node, const prev_node = try self.asNode().findAdjacentNodes(position); _ = try target_node.insertBefore(element.asNode(), prev_node, page); } pub fn insertAdjacentText( self: *Element, where: []const u8, data: []const u8, page: *Page, ) !void { const text_node = try page.createTextNode(data); const target_node, const prev_node = try self.asNode().findAdjacentNodes(where); _ = try target_node.insertBefore(text_node, prev_node, page); } pub fn setAttributeNode(self: *Element, attr: *Attribute, page: *Page) !?*Attribute { if (attr._element) |el| { if (el == self) { return attr; } attr._element = null; _ = try el.removeAttributeNode(attr, page); } const attributes = try self.getOrCreateAttributeList(page); return attributes.putAttribute(attr, self, page); } pub fn removeAttribute(self: *Element, name: String, page: *Page) !void { const attributes = self._attributes orelse return; return attributes.delete(name, self, page); } pub fn toggleAttribute(self: *Element, name: String, force: ?bool, page: *Page) !bool { try Attribute.validateAttributeName(name); const has = try self.hasAttribute(name, page); const should_add = force orelse !has; if (should_add and !has) { try self.setAttribute(name, String.empty, page); return true; } else if (!should_add and has) { try self.removeAttribute(name, page); return false; } return should_add; } pub fn removeAttributeNode(self: *Element, attr: *Attribute, page: *Page) !*Attribute { if (attr._element == null or attr._element.? != self) { return error.NotFound; } try self.removeAttribute(attr._name, page); attr._element = null; return attr; } pub fn getAttributeNames(self: *const Element, page: *Page) ![][]const u8 { const attributes = self._attributes orelse return &.{}; return attributes.getNames(page); } pub fn getAttributeNamedNodeMap(self: *Element, page: *Page) !*Attribute.NamedNodeMap { const gop = try page._attribute_named_node_map_lookup.getOrPut(page.arena, @intFromPtr(self)); if (!gop.found_existing) { const attributes = try self.getOrCreateAttributeList(page); const named_node_map = try page._factory.create(Attribute.NamedNodeMap{ ._list = attributes, ._element = self }); gop.value_ptr.* = named_node_map; } return gop.value_ptr.*; } pub fn getOrCreateStyle(self: *Element, page: *Page) !*CSSStyleProperties { const gop = try page._element_styles.getOrPut(page.arena, self); if (!gop.found_existing) { gop.value_ptr.* = try CSSStyleProperties.init(self, false, page); } return gop.value_ptr.*; } fn getStyle(self: *Element, page: *Page) ?*CSSStyleProperties { return page._element_styles.get(self); } pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList { const gop = try page._element_class_lists.getOrPut(page.arena, self); if (!gop.found_existing) { gop.value_ptr.* = try page._factory.create(collections.DOMTokenList{ ._element = self, ._attribute_name = comptime .wrap("class"), }); } return gop.value_ptr.*; } pub fn setClassList(self: *Element, value: String, page: *Page) !void { const class_list = try self.getClassList(page); try class_list.setValue(value, page); } pub fn getRelList(self: *Element, page: *Page) !*collections.DOMTokenList { const gop = try page._element_rel_lists.getOrPut(page.arena, self); if (!gop.found_existing) { gop.value_ptr.* = try page._factory.create(collections.DOMTokenList{ ._element = self, ._attribute_name = comptime .wrap("rel"), }); } return gop.value_ptr.*; } pub fn getDataset(self: *Element, page: *Page) !*DOMStringMap { const gop = try page._element_datasets.getOrPut(page.arena, self); if (!gop.found_existing) { gop.value_ptr.* = try page._factory.create(DOMStringMap{ ._element = self, }); } return gop.value_ptr.*; } pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { page.domChanged(); var parent = self.asNode(); var it = parent.childrenIterator(); while (it.next()) |child| { page.removeNode(parent, child, .{ .will_be_reconnected = false }); } const parent_is_connected = parent.isConnected(); for (nodes) |node_or_text| { var child_connected = false; const child = try node_or_text.toNode(page); if (child._parent) |previous_parent| { child_connected = child.isConnected(); page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); } try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); } } pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { page.domChanged(); const ref_node = self.asNode(); const parent = ref_node._parent orelse return; const parent_is_connected = parent.isConnected(); // Detect if the ref_node must be removed (byt default) or kept. // We kept it when ref_node is present into the nodes list. var rm_ref_node = true; for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); // If a child is the ref node. We keep it at its own current position. if (child == ref_node) { rm_ref_node = false; continue; } if (child._parent) |current_parent| { page.removeNode(current_parent, child, .{ .will_be_reconnected = parent_is_connected }); } try page.insertNodeRelative( parent, child, .{ .before = ref_node }, .{ .child_already_connected = child.isConnected() }, ); } if (rm_ref_node) { page.removeNode(parent, ref_node, .{ .will_be_reconnected = false }); } } pub fn remove(self: *Element, page: *Page) void { page.domChanged(); const node = self.asNode(); const parent = node._parent orelse return; page.removeNode(parent, node, .{ .will_be_reconnected = false }); } pub fn focus(self: *Element, page: *Page) !void { if (self.asNode().isConnected() == false) { // a disconnected node cannot take focus return; } const FocusEvent = @import("event/FocusEvent.zig"); const new_target = self.asEventTarget(); const old_active = page.document._active_element; page.document._active_element = self; if (old_active) |old| { if (old == self) { return; } const old_target = old.asEventTarget(); // Dispatch blur on old element (no bubble, composed) const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true, .relatedTarget = new_target }, page); try page._event_manager.dispatch(old_target, blur_event.asEvent()); // Dispatch focusout on old element (bubbles, composed) const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true, .relatedTarget = new_target }, page); try page._event_manager.dispatch(old_target, focusout_event.asEvent()); } const old_related: ?*EventTarget = if (old_active) |old| old.asEventTarget() else null; // Dispatch focus on new element (no bubble, composed) const focus_event = try FocusEvent.initTrusted(comptime .wrap("focus"), .{ .composed = true, .relatedTarget = old_related }, page); try page._event_manager.dispatch(new_target, focus_event.asEvent()); // Dispatch focusin on new element (bubbles, composed) const focusin_event = try FocusEvent.initTrusted(comptime .wrap("focusin"), .{ .bubbles = true, .composed = true, .relatedTarget = old_related }, page); try page._event_manager.dispatch(new_target, focusin_event.asEvent()); } pub fn blur(self: *Element, page: *Page) !void { if (page.document._active_element != self) return; page.document._active_element = null; const FocusEvent = @import("event/FocusEvent.zig"); const old_target = self.asEventTarget(); // Dispatch blur (no bubble, composed) const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true }, page); try page._event_manager.dispatch(old_target, blur_event.asEvent()); // Dispatch focusout (bubbles, composed) const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true }, page); try page._event_manager.dispatch(old_target, focusout_event.asEvent()); } pub fn getChildren(self: *Element, page: *Page) !collections.NodeLive(.child_elements) { return collections.NodeLive(.child_elements).init(self.asNode(), {}, page); } pub fn append(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { const parent = self.asNode(); for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); _ = try parent.appendChild(child, page); } } pub fn prepend(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { const parent = self.asNode(); var i = nodes.len; while (i > 0) { i -= 1; const child = try nodes[i].toNode(page); _ = try parent.insertBefore(child, parent.firstChild(), page); } } pub fn before(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { const node = self.asNode(); const parent = node.parentNode() orelse return; for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); _ = try parent.insertBefore(child, node, page); } } pub fn after(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { const node = self.asNode(); const parent = node.parentNode() orelse return; const viable_next = Node.NodeOrText.viableNextSibling(node, nodes); for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); _ = try parent.insertBefore(child, viable_next, page); } } pub fn firstElementChild(self: *Element) ?*Element { var maybe_child = self.asNode().firstChild(); while (maybe_child) |child| { if (child.is(Element)) |el| return el; maybe_child = child.nextSibling(); } return null; } pub fn lastElementChild(self: *Element) ?*Element { var maybe_child = self.asNode().lastChild(); while (maybe_child) |child| { if (child.is(Element)) |el| return el; maybe_child = child.previousSibling(); } return null; } pub fn nextElementSibling(self: *Element) ?*Element { var maybe_sibling = self.asNode().nextSibling(); while (maybe_sibling) |sibling| { if (sibling.is(Element)) |el| return el; maybe_sibling = sibling.nextSibling(); } return null; } pub fn previousElementSibling(self: *Element) ?*Element { var maybe_sibling = self.asNode().previousSibling(); while (maybe_sibling) |sibling| { if (sibling.is(Element)) |el| return el; maybe_sibling = sibling.previousSibling(); } return null; } pub fn getChildElementCount(self: *Element) usize { var count: usize = 0; var it = self.asNode().childrenIterator(); while (it.next()) |node| { if (node.is(Element) != null) { count += 1; } } return count; } pub fn matches(self: *Element, selector: []const u8, page: *Page) !bool { return Selector.matches(self, selector, page); } pub fn querySelector(self: *Element, selector: []const u8, page: *Page) !?*Element { return Selector.querySelector(self.asNode(), selector, page); } pub fn querySelectorAll(self: *Element, input: []const u8, page: *Page) !*Selector.List { return Selector.querySelectorAll(self.asNode(), input, page); } pub fn getAnimations(_: *const Element) []*Animation { return &.{}; } pub fn animate(_: *Element, _: ?js.Object, _: ?js.Object, page: *Page) !*Animation { return Animation.init(page); } pub fn closest(self: *Element, selector: []const u8, page: *Page) !?*Element { if (selector.len == 0) { return error.SyntaxError; } var current: ?*Element = self; while (current) |el| { if (try Selector.matchesWithScope(el, selector, self, page)) { return el; } const parent = el._proto._parent orelse break; if (parent.is(ShadowRoot) != null) { break; } current = parent.is(Element); } return null; } pub fn parentElement(self: *Element) ?*Element { return self._proto.parentElement(); } pub fn checkVisibility(self: *Element, page: *Page) bool { var current: ?*Element = self; while (current) |el| { if (el.getStyle(page)) |style| { const display = style.asCSSStyleDeclaration().getPropertyValue("display", page); if (std.mem.eql(u8, display, "none")) { return false; } } current = el.parentElement(); } return true; } fn getElementDimensions(self: *Element, page: *Page) struct { width: f64, height: f64 } { var width: f64 = 5.0; var height: f64 = 5.0; if (self.getStyle(page)) |style| { const decl = style.asCSSStyleDeclaration(); width = CSS.parseDimension(decl.getPropertyValue("width", page)) orelse 5.0; height = CSS.parseDimension(decl.getPropertyValue("height", page)) orelse 5.0; } if (width == 5.0 or height == 5.0) { const tag = self.getTag(); // Root containers get large default size to contain descendant positions. // With calculateDocumentPosition using linear depth scaling (100px per level), // even very deep trees (100 levels) stay within 10,000px. // 100M pixels is plausible for very long documents. if (tag == .html or tag == .body) { if (width == 5.0) width = 1920.0; if (height == 5.0) height = 100_000_000.0; } else if (tag == .img or tag == .iframe) { if (self.getAttributeSafe(comptime .wrap("width"))) |w| { width = std.fmt.parseFloat(f64, w) catch width; } if (self.getAttributeSafe(comptime .wrap("height"))) |h| { height = std.fmt.parseFloat(f64, h) catch height; } } } return .{ .width = width, .height = height }; } pub fn getClientWidth(self: *Element, page: *Page) f64 { if (!self.checkVisibility(page)) { return 0.0; } const dims = self.getElementDimensions(page); return dims.width; } pub fn getClientHeight(self: *Element, page: *Page) f64 { if (!self.checkVisibility(page)) { return 0.0; } const dims = self.getElementDimensions(page); return dims.height; } pub fn getBoundingClientRect(self: *Element, page: *Page) DOMRect { if (!self.checkVisibility(page)) { return .{ ._x = 0.0, ._y = 0.0, ._width = 0.0, ._height = 0.0, }; } return self.getBoundingClientRectForVisible(page); } // Some cases need a the BoundingClientRect but have already done the // visibility check. pub fn getBoundingClientRectForVisible(self: *Element, page: *Page) DOMRect { const y = calculateDocumentPosition(self.asNode()); const dims = self.getElementDimensions(page); // Use sibling position for x coordinate to ensure siblings have different x values const x = calculateSiblingPosition(self.asNode()); return .{ ._x = x, ._y = y, ._width = dims.width, ._height = dims.height, }; } pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect { if (!self.checkVisibility(page)) { return &.{}; } const rects = try page.call_arena.alloc(DOMRect, 1); rects[0] = self.getBoundingClientRectForVisible(page); return rects; } pub fn getScrollTop(self: *Element, page: *Page) u32 { const pos = page._element_scroll_positions.get(self) orelse return 0; return pos.y; } pub fn setScrollTop(self: *Element, value: i32, page: *Page) !void { const gop = try page._element_scroll_positions.getOrPut(page.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } gop.value_ptr.y = @intCast(@max(0, value)); } pub fn getScrollLeft(self: *Element, page: *Page) u32 { const pos = page._element_scroll_positions.get(self) orelse return 0; return pos.x; } pub fn setScrollLeft(self: *Element, value: i32, page: *Page) !void { const gop = try page._element_scroll_positions.getOrPut(page.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } gop.value_ptr.x = @intCast(@max(0, value)); } pub fn getScrollHeight(self: *Element, page: *Page) f64 { // In our dummy layout engine, content doesn't overflow return self.getClientHeight(page); } pub fn getScrollWidth(self: *Element, page: *Page) f64 { // In our dummy layout engine, content doesn't overflow return self.getClientWidth(page); } pub fn getOffsetHeight(self: *Element, page: *Page) f64 { if (!self.checkVisibility(page)) { return 0.0; } const dims = self.getElementDimensions(page); return dims.height; } pub fn getOffsetWidth(self: *Element, page: *Page) f64 { if (!self.checkVisibility(page)) { return 0.0; } const dims = self.getElementDimensions(page); return dims.width; } pub fn getOffsetTop(self: *Element, page: *Page) f64 { if (!self.checkVisibility(page)) { return 0.0; } return calculateDocumentPosition(self.asNode()); } pub fn getOffsetLeft(self: *Element, page: *Page) f64 { if (!self.checkVisibility(page)) { return 0.0; } return calculateSiblingPosition(self.asNode()); } pub fn getClientTop(_: *Element) f64 { // Border width - in our dummy layout, we don't apply borders to layout return 0.0; } pub fn getClientLeft(_: *Element) f64 { // Border width - in our dummy layout, we don't apply borders to layout return 0.0; } // Calculates document position by counting all nodes that appear before this one // in tree order, but only traversing the "left side" of the tree. // // This walks up from the target node to the root, and at each level counts: // 1. All previous siblings and their descendants // 2. The parent itself // // Example: // → y=0 //

Text

→ y=1 (body=1) //

→ y=2 (body=1 + h1=1) // Link1 → y=3 (body=1 + h1=1 + h2=1) //

//

Text

→ y=5 (body=1 + h1=1 + h2=2) //

→ y=6 (body=1 + h1=1 + h2=2 + p=1) // Link2 → y=7 (body=1 + h1=1 + h2=2 + p=1 + h2=1) //

// // // Trade-offs: // - O(depth × siblings × subtree_height) - only left-side traversal // - Linear scaling: 5px per node // - Perfect document order, guaranteed unique positions // - Compact coordinates (1000 nodes ≈ 5,000px) fn calculateDocumentPosition(node: *Node) f64 { var position: f64 = 0.0; var current = node; // Walk up to root, counting preceding nodes while (current.parentNode()) |parent| { // Count all previous siblings and their descendants var sibling = parent.firstChild(); while (sibling) |s| { if (s == current) break; position += countSubtreeNodes(s); sibling = s.nextSibling(); } // Count the parent itself position += 1.0; current = parent; } return position * 5.0; // 5px per node } // Counts total nodes in a subtree (node + all descendants) fn countSubtreeNodes(node: *Node) f64 { var count: f64 = 1.0; // Count this node var child = node.firstChild(); while (child) |c| { count += countSubtreeNodes(c); child = c.nextSibling(); } return count; } // Calculates horizontal position using the same approach as y, // just scaled differently for visual distinction fn calculateSiblingPosition(node: *Node) f64 { var position: f64 = 0.0; var current = node; // Walk up to root, counting preceding nodes (same as y) while (current.parentNode()) |parent| { // Count all previous siblings and their descendants var sibling = parent.firstChild(); while (sibling) |s| { if (s == current) break; position += countSubtreeNodes(s); sibling = s.nextSibling(); } // Count the parent itself position += 1.0; current = parent; } return position * 5.0; // 5px per node } pub fn getElementsByTagName(self: *Element, tag_name: []const u8, page: *Page) !Node.GetElementsByTagNameResult { return self.asNode().getElementsByTagName(tag_name, page); } pub fn getElementsByTagNameNS(self: *Element, namespace: ?[]const u8, local_name: []const u8, page: *Page) !collections.NodeLive(.tag_name_ns) { return self.asNode().getElementsByTagNameNS(namespace, local_name, page); } pub fn getElementsByClassName(self: *Element, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) { return self.asNode().getElementsByClassName(class_name, page); } pub fn clone(self: *Element, deep: bool, page: *Page) !*Node { const tag_name = self.getTagNameDump(); const node = try page.createElementNS(self._namespace, tag_name, self._attributes); // Allow element-specific types to copy their runtime state _ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), page }) catch |err| { log.err(.dom, "element.clone.failed", .{ .err = err }); }; if (deep) { var child_it = self.asNode().childrenIterator(); while (child_it.next()) |child| { if (try child.cloneNodeForAppending(true, page)) |cloned_child| { // We pass `true` to `child_already_connected` as a hacky optimization // We _know_ this child isn't connected (Because the parent isn't connected) // setting this to `true` skips all connection checks. try page.appendNode(node, cloned_child, .{ .child_already_connected = true }); } } } return node; } pub fn scrollIntoViewIfNeeded(_: *const Element, center_if_needed: ?bool) void { _ = center_if_needed; } const ScrollIntoViewOpts = union { align_to_top: bool, obj: js.Object, }; pub fn scrollIntoView(_: *const Element, opts: ?ScrollIntoViewOpts) void { _ = opts; } pub fn format(self: *Element, writer: *std.Io.Writer) !void { try writer.writeByte('<'); try writer.writeAll(self.getTagNameDump()); if (self._attributes) |attributes| { var it = attributes.iterator(); while (it.next()) |attr| { try writer.print(" {f}", .{attr}); } } try writer.writeByte('>'); } fn upperTagName(tag_name: *String, buf: []u8) []const u8 { if (tag_name.len > buf.len) { log.info(.dom, "tag.long.name", .{ .name = tag_name.str() }); return tag_name.str(); } const tag = tag_name.str(); return std.ascii.upperString(buf, tag); } pub fn getTag(self: *const Element) Tag { return switch (self._type) { .html => |he| switch (he._type) { .anchor => .anchor, .area => .area, .base => .base, .div => .div, .dl => .dl, .embed => .embed, .form => .form, .p => .p, .custom => .custom, .data => .data, .datalist => .datalist, .details => .details, .dialog => .dialog, .directory => .directory, .iframe => .iframe, .img => .img, .br => .br, .button => .button, .canvas => .canvas, .fieldset => .fieldset, .font => .font, .heading => |h| h._tag, .label => .label, .legend => .legend, .li => .li, .map => .map, .ul => .ul, .ol => .ol, .object => .object, .optgroup => .optgroup, .output => .output, .picture => .picture, .param => .param, .pre => .pre, .generic => |g| g._tag, .media => |m| switch (m._type) { .audio => .audio, .video => .video, .generic => .media, }, .meter => .meter, .mod => |m| m._tag, .progress => .progress, .quote => |q| q._tag, .script => .script, .select => .select, .slot => .slot, .source => .source, .span => .span, .option => .option, .table => .table, .table_caption => .caption, .table_cell => |tc| tc._tag, .table_col => |tc| tc._tag, .table_row => .tr, .table_section => |ts| ts._tag, .template => .template, .textarea => .textarea, .time => .time, .track => .track, .input => .input, .link => .link, .meta => .meta, .hr => .hr, .style => .style, .title => .title, .body => .body, .html => .html, .head => .head, .unknown => .unknown, }, .svg => |se| switch (se._type) { .svg => .svg, .generic => |g| g._tag, }, }; } pub const Tag = enum { address, anchor, audio, area, aside, article, b, blockquote, body, br, button, base, canvas, caption, circle, code, col, colgroup, custom, data, datalist, dd, details, del, dfn, dialog, div, directory, dl, dt, embed, ellipse, em, fieldset, figure, form, font, footer, g, h1, h2, h3, h4, h5, h6, head, header, heading, hgroup, hr, html, i, iframe, img, input, ins, label, legend, li, line, link, main, map, marquee, media, menu, meta, meter, nav, noscript, object, ol, optgroup, option, output, p, path, param, picture, polygon, polyline, pre, progress, quote, rect, s, script, section, select, slot, source, span, strong, style, sub, summary, sup, svg, table, time, tbody, td, text, template, textarea, tfoot, th, thead, title, tr, track, ul, video, unknown, // If the tag is "unknown", we can't use the optimized tag matching, but // need to fallback to the actual tag name pub fn parseForMatch(lower: []const u8) ?Tag { const tag = std.meta.stringToEnum(Tag, lower) orelse return null; return switch (tag) { .unknown, .custom => null, else => tag, }; } pub fn isBlock(self: Tag) bool { // zig fmt: off return switch (self) { // Semantic Layout .article, .aside, .footer, .header, .main, .nav, .section, // Grouping / Containers .address, .div, .fieldset, .figure, .p, // Headings .h1, .h2, .h3, .h4, .h5, .h6, // Lists .dl, .ol, .ul, // Preformatted / Quotes .blockquote, .pre, // Tables .table, // Other .hr, => true, else => false, }; // zig fmt: on } pub fn isMetadata(self: Tag) bool { return switch (self) { .base, .head, .link, .meta, .noscript, .script, .style, .template, .title => true, else => false, }; } }; pub const JsApi = struct { pub const bridge = js.Bridge(Element); pub const Meta = struct { pub const name = "Element"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const tagName = bridge.accessor(_tagName, null, .{}); fn _tagName(self: *Element, page: *Page) []const u8 { return self.getTagNameSpec(&page.buf); } pub const namespaceURI = bridge.accessor(Element.getNamespaceURI, null, .{}); pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{}); fn _innerText(self: *Element, page: *const Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getInnerText(&buf.writer); return buf.written(); } pub const outerHTML = bridge.accessor(_outerHTML, Element.setOuterHTML, .{}); fn _outerHTML(self: *Element, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getOuterHTML(&buf.writer, page); return buf.written(); } pub const innerHTML = bridge.accessor(_innerHTML, Element.setInnerHTML, .{}); fn _innerHTML(self: *Element, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getInnerHTML(&buf.writer, page); return buf.written(); } pub const prefix = bridge.accessor(Element._prefix, null, .{}); pub const setAttribute = bridge.function(_setAttribute, .{ .dom_exception = true }); fn _setAttribute(self: *Element, name: String, value: js.Value, page: *Page) !void { return self.setAttribute(name, .wrap(try value.toStringSlice()), page); } pub const setAttributeNS = bridge.function(_setAttributeNS, .{ .dom_exception = true }); fn _setAttributeNS(self: *Element, maybe_ns: ?[]const u8, qn: []const u8, value: js.Value, page: *Page) !void { return self.setAttributeNS(maybe_ns, qn, .wrap(try value.toStringSlice()), page); } pub const localName = bridge.accessor(Element.getLocalName, null, .{}); pub const id = bridge.accessor(Element.getId, Element.setId, .{}); pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{}); pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{}); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{}); pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{}); pub const dataset = bridge.accessor(Element.getDataset, null, .{}); pub const style = bridge.accessor(Element.getOrCreateStyle, null, .{}); pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{}); pub const hasAttribute = bridge.function(Element.hasAttribute, .{}); pub const hasAttributes = bridge.function(Element.hasAttributes, .{}); pub const getAttribute = bridge.function(Element.getAttribute, .{}); pub const getAttributeNS = bridge.function(Element.getAttributeNS, .{}); pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{}); pub const setAttributeNode = bridge.function(Element.setAttributeNode, .{}); pub const removeAttribute = bridge.function(Element.removeAttribute, .{}); pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{ .dom_exception = true }); pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{}); pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true }); pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{}); pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{}); pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true }); pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true }); pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .dom_exception = true }); pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .dom_exception = true }); const ShadowRootInit = struct { mode: []const u8, }; fn _attachShadow(self: *Element, init: ShadowRootInit, page: *Page) !*ShadowRoot { return self.attachShadow(init.mode, page); } pub const replaceChildren = bridge.function(Element.replaceChildren, .{ .dom_exception = true }); pub const replaceWith = bridge.function(Element.replaceWith, .{ .dom_exception = true }); pub const remove = bridge.function(Element.remove, .{}); pub const append = bridge.function(Element.append, .{ .dom_exception = true }); pub const prepend = bridge.function(Element.prepend, .{ .dom_exception = true }); pub const before = bridge.function(Element.before, .{ .dom_exception = true }); pub const after = bridge.function(Element.after, .{ .dom_exception = true }); pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{}); pub const lastElementChild = bridge.accessor(Element.lastElementChild, null, .{}); pub const nextElementSibling = bridge.accessor(Element.nextElementSibling, null, .{}); pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{}); pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{}); pub const matches = bridge.function(Element.matches, .{ .dom_exception = true }); pub const querySelector = bridge.function(Element.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{ .dom_exception = true }); pub const closest = bridge.function(Element.closest, .{ .dom_exception = true }); pub const getAnimations = bridge.function(Element.getAnimations, .{}); pub const animate = bridge.function(Element.animate, .{}); pub const checkVisibility = bridge.function(Element.checkVisibility, .{}); pub const clientWidth = bridge.accessor(Element.getClientWidth, null, .{}); pub const clientHeight = bridge.accessor(Element.getClientHeight, null, .{}); pub const clientTop = bridge.accessor(Element.getClientTop, null, .{}); pub const clientLeft = bridge.accessor(Element.getClientLeft, null, .{}); pub const scrollTop = bridge.accessor(Element.getScrollTop, Element.setScrollTop, .{}); pub const scrollLeft = bridge.accessor(Element.getScrollLeft, Element.setScrollLeft, .{}); pub const scrollHeight = bridge.accessor(Element.getScrollHeight, null, .{}); pub const scrollWidth = bridge.accessor(Element.getScrollWidth, null, .{}); pub const offsetTop = bridge.accessor(Element.getOffsetTop, null, .{}); pub const offsetLeft = bridge.accessor(Element.getOffsetLeft, null, .{}); pub const offsetWidth = bridge.accessor(Element.getOffsetWidth, null, .{}); pub const offsetHeight = bridge.accessor(Element.getOffsetHeight, null, .{}); pub const getClientRects = bridge.function(Element.getClientRects, .{}); pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{}); pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{}); pub const getElementsByTagNameNS = bridge.function(Element.getElementsByTagNameNS, .{}); pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{}); pub const children = bridge.accessor(Element.getChildren, null, .{}); pub const focus = bridge.function(Element.focus, .{}); pub const blur = bridge.function(Element.blur, .{}); pub const scrollIntoView = bridge.function(Element.scrollIntoView, .{}); pub const scrollIntoViewIfNeeded = bridge.function(Element.scrollIntoViewIfNeeded, .{}); }; pub const Build = struct { // Calls `func_name` with `args` on the most specific type where it is // implement. This could be on the Element itself. pub fn call(self: *const Element, comptime func_name: []const u8, args: anytype) !bool { inline for (@typeInfo(Element.Type).@"union".fields) |f| { if (@field(Element.Type, f.name) == self._type) { // The inner type implements this function. Call it and we're done. const S = reflect.Struct(f.type); if (@hasDecl(S, "Build")) { if (@hasDecl(S.Build, "call")) { const sub = @field(self._type, f.name); return S.Build.call(sub, func_name, args); } // The inner type implements this function. Call it and we're done. if (@hasDecl(f.type, func_name)) { return @call(.auto, @field(f.type, func_name), args); } } } } if (@hasDecl(Element.Build, func_name)) { // Our last resort - the element implements this function. try @call(.auto, @field(Element.Build, func_name), args); return true; } // inform our caller (the Node) that we didn't find anything that implemented // func_name and it should keep searching for a match. return false; } }; const testing = @import("../../testing.zig"); test "WebApi: Element" { try testing.htmlRunner("element", .{}); } ================================================ FILE: src/browser/webapi/Event.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const EventTarget = @import("EventTarget.zig"); const Node = @import("Node.zig"); const String = @import("../../string.zig").String; const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; pub const Event = @This(); pub const _prototype_root = true; _type: Type, _arena: Allocator, _bubbles: bool = false, _cancelable: bool = false, _composed: bool = false, _type_string: String, _target: ?*EventTarget = null, _current_target: ?*EventTarget = null, _dispatch_target: ?*EventTarget = null, // Original target for composedPath() _prevent_default: bool = false, _stop_propagation: bool = false, _stop_immediate_propagation: bool = false, _event_phase: EventPhase = .none, _time_stamp: u64, _needs_retargeting: bool = false, _is_trusted: bool = false, // There's a period of time between creating an event and handing it off to v8 // where things can fail. If it does fail, we need to deinit the event. The timing // window can be difficult to capture, so we use a reference count. // should be 0, 1, or 2. 0 // - 0: no reference, always a transient state going to either 1 or about to be deinit'd // - 1: either zig or v8 have a reference // - 2: both zig and v8 have a reference _rc: u8 = 0, pub const EventPhase = enum(u8) { none = 0, capturing_phase = 1, at_target = 2, bubbling_phase = 3, }; pub const Type = union(enum) { generic, error_event: *@import("event/ErrorEvent.zig"), custom_event: *@import("event/CustomEvent.zig"), message_event: *@import("event/MessageEvent.zig"), progress_event: *@import("event/ProgressEvent.zig"), composition_event: *@import("event/CompositionEvent.zig"), navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), page_transition_event: *@import("event/PageTransitionEvent.zig"), pop_state_event: *@import("event/PopStateEvent.zig"), ui_event: *@import("event/UIEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), }; pub const Options = struct { bubbles: bool = false, cancelable: bool = false, composed: bool = false, }; pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { const arena = try page.getArena(.{ .debug = "Event" }); errdefer page.releaseArena(arena); const str = try String.init(arena, typ, .{}); return initWithTrusted(arena, str, opts_, false); } pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { const arena = try page.getArena(.{ .debug = "Event.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, opts_, true); } fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, comptime trusted: bool) !*Event { const opts = opts_ orelse Options{}; // Round to 2ms for privacy (browsers do this) const raw_timestamp = @import("../../datetime.zig").milliTimestamp(.monotonic); const time_stamp = (raw_timestamp / 2) * 2; const event = try arena.create(Event); event.* = .{ ._arena = arena, ._type = .generic, ._bubbles = opts.bubbles, ._time_stamp = time_stamp, ._cancelable = opts.cancelable, ._composed = opts.composed, ._type_string = typ, ._is_trusted = trusted, }; return event; } pub fn initEvent( self: *Event, event_string: []const u8, bubbles: ?bool, cancelable: ?bool, ) !void { if (self._event_phase != .none) { return; } self._type_string = try String.init(self._arena, event_string, .{}); self._bubbles = bubbles orelse false; self._cancelable = cancelable orelse false; self._stop_propagation = false; self._stop_immediate_propagation = false; self._prevent_default = false; } pub fn acquireRef(self: *Event) void { self._rc += 1; } pub fn deinit(self: *Event, shutdown: bool, session: *Session) void { if (shutdown) { session.releaseArena(self._arena); return; } const rc = self._rc; if (comptime IS_DEBUG) { std.debug.assert(rc != 0); } if (rc == 1) { session.releaseArena(self._arena); } else { self._rc = rc - 1; } } pub fn as(self: *Event, comptime T: type) *T { return self.is(T).?; } pub fn is(self: *Event, comptime T: type) ?*T { switch (self._type) { .generic => return if (T == Event) self else null, .error_event => |e| return if (T == @import("event/ErrorEvent.zig")) e else null, .custom_event => |e| return if (T == @import("event/CustomEvent.zig")) e else null, .message_event => |e| return if (T == @import("event/MessageEvent.zig")) e else null, .progress_event => |e| return if (T == @import("event/ProgressEvent.zig")) e else null, .composition_event => |e| return if (T == @import("event/CompositionEvent.zig")) e else null, .navigation_current_entry_change_event => |e| return if (T == @import("event/NavigationCurrentEntryChangeEvent.zig")) e else null, .page_transition_event => |e| return if (T == @import("event/PageTransitionEvent.zig")) e else null, .pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, .ui_event => |e| { if (T == @import("event/UIEvent.zig")) { return e; } return e.is(T); }, } return null; } pub fn getType(self: *const Event) []const u8 { return self._type_string.str(); } pub fn getBubbles(self: *const Event) bool { return self._bubbles; } pub fn getCancelable(self: *const Event) bool { return self._cancelable; } pub fn getComposed(self: *const Event) bool { return self._composed; } pub fn getTarget(self: *const Event) ?*EventTarget { return self._target; } pub fn getCurrentTarget(self: *const Event) ?*EventTarget { return self._current_target; } pub fn preventDefault(self: *Event) void { if (self._cancelable) { self._prevent_default = true; } } pub fn stopPropagation(self: *Event) void { self._stop_propagation = true; } pub fn stopImmediatePropagation(self: *Event) void { self._stop_immediate_propagation = true; self._stop_propagation = true; } pub fn getDefaultPrevented(self: *const Event) bool { return self._prevent_default; } pub fn getReturnValue(self: *const Event) bool { return !self._prevent_default; } pub fn setReturnValue(self: *Event, v: bool) void { if (!v) { // Setting returnValue=false is equivalent to preventDefault() if (self._cancelable) { self._prevent_default = true; } } } pub fn getCancelBubble(self: *const Event) bool { return self._stop_propagation; } pub fn setCancelBubble(self: *Event) void { self.stopPropagation(); } pub fn getEventPhase(self: *const Event) u8 { return @intFromEnum(self._event_phase); } pub fn getTimeStamp(self: *const Event) u64 { return self._time_stamp; } pub fn setTrusted(self: *Event) void { self._is_trusted = true; } pub fn setUntrusted(self: *Event) void { self._is_trusted = false; } pub fn getIsTrusted(self: *const Event) bool { return self._is_trusted; } pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget { // Return empty array if event is not being dispatched if (self._event_phase == .none) { return &.{}; } // Use dispatch_target (original target) if available, otherwise fall back to target // This is important because _target gets retargeted during event dispatch const target = self._dispatch_target orelse self._target orelse return &.{}; // Only nodes have a propagation path const target_node = switch (target._type) { .node => |n| n, else => return &.{}, }; // Build the path by walking up from target var path_len: usize = 0; var path_buffer: [128]*EventTarget = undefined; var stopped_at_shadow_boundary = false; // Track closed shadow boundaries (position in path and host position) var closed_shadow_boundary: ?struct { shadow_end: usize, host_start: usize } = null; var node: ?*Node = target_node; while (node) |n| { if (path_len >= path_buffer.len) { break; } path_buffer[path_len] = n.asEventTarget(); path_len += 1; // Check if this node is a shadow root if (n._type == .document_fragment) { if (n._type.document_fragment._type == .shadow_root) { const shadow = n._type.document_fragment._type.shadow_root; // If event is not composed, stop at shadow boundary if (!self._composed) { stopped_at_shadow_boundary = true; break; } // Track the first closed shadow boundary we encounter if (shadow._mode == .closed and closed_shadow_boundary == null) { // Mark where the shadow root is in the path // The next element will be the host closed_shadow_boundary = .{ .shadow_end = path_len - 1, // index of shadow root .host_start = path_len, // index where host will be }; } // Jump to the shadow host and continue node = shadow._host.asNode(); continue; } } node = n._parent; } // Add window at the end (unless we stopped at shadow boundary) if (!stopped_at_shadow_boundary) { if (path_len < path_buffer.len) { path_buffer[path_len] = page.window.asEventTarget(); path_len += 1; } } // Determine visible path based on current_target and closed shadow boundaries var visible_start_index: usize = 0; if (closed_shadow_boundary) |boundary| { // Check if current_target is outside the closed shadow // If current_target is null or is at/after the host position, hide shadow internals const current_target = self._current_target; if (current_target) |ct| { // Find current_target in the path var ct_index: ?usize = null; for (path_buffer[0..path_len], 0..) |elem, i| { if (elem == ct) { ct_index = i; break; } } // If current_target is at or after the host (outside the closed shadow), // hide everything from target up to the host if (ct_index) |idx| { if (idx >= boundary.host_start) { visible_start_index = boundary.host_start; } } } } // Calculate the visible portion of the path const visible_path_len = if (path_len > visible_start_index) path_len - visible_start_index else 0; // Allocate and return the visible path using call_arena (short-lived) const path = try page.call_arena.alloc(*EventTarget, visible_path_len); @memcpy(path, path_buffer[visible_start_index..path_len]); return path; } pub fn populateFromOptions(self: *Event, opts: anytype) void { self._bubbles = opts.bubbles; self._cancelable = opts.cancelable; self._composed = opts.composed; } pub fn inheritOptions(comptime T: type, comptime additions: anytype) type { var all_fields: []const std.builtin.Type.StructField = &.{}; if (@hasField(T, "_proto")) { const t_fields = @typeInfo(T).@"struct".fields; inline for (t_fields) |field| { if (std.mem.eql(u8, field.name, "_proto")) { const ProtoType = @typeInfo(field.type).pointer.child; if (@hasDecl(ProtoType, "Options")) { const parent_options = @typeInfo(ProtoType.Options); all_fields = all_fields ++ parent_options.@"struct".fields; } } } } const additions_info = @typeInfo(additions); all_fields = all_fields ++ additions_info.@"struct".fields; return @Type(.{ .@"struct" = .{ .layout = .auto, .fields = all_fields, .decls = &.{}, .is_tuple = false, }, }); } pub fn populatePrototypes(self: anytype, opts: anytype, trusted: bool) void { const T = @TypeOf(self.*); if (@hasField(T, "_proto")) { populatePrototypes(self._proto, opts, trusted); } if (@hasDecl(T, "populateFromOptions")) { T.populateFromOptions(self, opts); } // Set isTrusted at the Event level (base of prototype chain) if (T == Event or @hasField(T, "is_trusted")) { self._is_trusted = trusted; } } pub const JsApi = struct { pub const bridge = js.Bridge(Event); pub const Meta = struct { pub const name = "Event"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(Event.deinit); pub const enumerable = false; }; pub const constructor = bridge.constructor(Event.init, .{}); pub const @"type" = bridge.accessor(Event.getType, null, .{}); pub const bubbles = bridge.accessor(Event.getBubbles, null, .{}); pub const cancelable = bridge.accessor(Event.getCancelable, null, .{}); pub const composed = bridge.accessor(Event.getComposed, null, .{}); pub const target = bridge.accessor(Event.getTarget, null, .{}); pub const srcElement = bridge.accessor(Event.getTarget, null, .{}); pub const currentTarget = bridge.accessor(Event.getCurrentTarget, null, .{}); pub const eventPhase = bridge.accessor(Event.getEventPhase, null, .{}); pub const defaultPrevented = bridge.accessor(Event.getDefaultPrevented, null, .{}); pub const timeStamp = bridge.accessor(Event.getTimeStamp, null, .{}); pub const isTrusted = bridge.accessor(Event.getIsTrusted, null, .{}); pub const preventDefault = bridge.function(Event.preventDefault, .{}); pub const stopPropagation = bridge.function(Event.stopPropagation, .{}); pub const stopImmediatePropagation = bridge.function(Event.stopImmediatePropagation, .{}); pub const composedPath = bridge.function(Event.composedPath, .{}); pub const initEvent = bridge.function(Event.initEvent, .{}); // deprecated pub const returnValue = bridge.accessor(Event.getReturnValue, Event.setReturnValue, .{}); // deprecated pub const cancelBubble = bridge.accessor(Event.getCancelBubble, Event.setCancelBubble, .{}); // Event phase constants pub const NONE = bridge.property(@intFromEnum(EventPhase.none), .{ .template = true }); pub const CAPTURING_PHASE = bridge.property(@intFromEnum(EventPhase.capturing_phase), .{ .template = true }); pub const AT_TARGET = bridge.property(@intFromEnum(EventPhase.at_target), .{ .template = true }); pub const BUBBLING_PHASE = bridge.property(@intFromEnum(EventPhase.bubbling_phase), .{ .template = true }); }; // tested in event_target ================================================ FILE: src/browser/webapi/EventTarget.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const EventManager = @import("../EventManager.zig"); const RegisterOptions = EventManager.RegisterOptions; const Event = @import("Event.zig"); const EventTarget = @This(); pub const _prototype_root = true; _type: Type, pub const Type = union(enum) { generic: void, node: *@import("Node.zig"), window: *@import("Window.zig"), xhr: *@import("net/XMLHttpRequestEventTarget.zig"), abort_signal: *@import("AbortSignal.zig"), media_query_list: *@import("css/MediaQueryList.zig"), message_port: *@import("MessagePort.zig"), text_track_cue: *@import("media/TextTrackCue.zig"), navigation: *@import("navigation/Navigation.zig"), screen: *@import("Screen.zig"), screen_orientation: *@import("Screen.zig").Orientation, visual_viewport: *@import("VisualViewport.zig"), file_reader: *@import("FileReader.zig"), font_face_set: *@import("css/FontFaceSet.zig"), }; pub fn init(page: *Page) !*EventTarget { return page._factory.create(EventTarget{ ._type = .generic, }); } pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { if (event._event_phase != .none) { return error.InvalidStateError; } event._is_trusted = false; event.acquireRef(); defer event.deinit(false, page._session); try page._event_manager.dispatch(self, event); return !event._cancelable or !event._prevent_default; } const AddEventListenerOptions = union(enum) { capture: bool, options: RegisterOptions, }; pub const EventListenerCallback = union(enum) { function: js.Function, object: js.Object, }; pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, page: *Page) !void { const callback = callback_ orelse return; const em_callback = switch (callback) { .object => |obj| EventManager.Callback{ .object = obj }, .function => |func| EventManager.Callback{ .function = func }, }; const options = blk: { const o = opts_ orelse break :blk RegisterOptions{}; break :blk switch (o) { .options => |opts| opts, .capture => |capture| RegisterOptions{ .capture = capture }, }; }; return page._event_manager.register(self, typ, em_callback, options); } const RemoveEventListenerOptions = union(enum) { capture: bool, options: Options, const Options = struct { capture: bool = false, }; }; pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, page: *Page) !void { const callback = callback_ orelse return; // For object callbacks, check if handleEvent exists if (callback == .object) { if (try callback.object.getFunction("handleEvent") == null) { return; } } const em_callback = switch (callback) { .function => |func| EventManager.Callback{ .function = func }, .object => |obj| EventManager.Callback{ .object = obj }, }; const use_capture = blk: { const o = opts_ orelse break :blk false; break :blk switch (o) { .capture => |capture| capture, .options => |opts| opts.capture, }; }; return page._event_manager.remove(self, typ, em_callback, use_capture); } pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { return switch (self._type) { .node => |n| n.format(writer), .generic => writer.writeAll(""), .window => writer.writeAll(""), .xhr => writer.writeAll(""), .abort_signal => writer.writeAll(""), .media_query_list => writer.writeAll(""), .message_port => writer.writeAll(""), .text_track_cue => writer.writeAll(""), .navigation => writer.writeAll(""), .screen => writer.writeAll(""), .screen_orientation => writer.writeAll(""), .visual_viewport => writer.writeAll(""), .file_reader => writer.writeAll(""), .font_face_set => writer.writeAll(""), }; } pub fn toString(self: *EventTarget) []const u8 { return switch (self._type) { .node => return "[object Node]", .generic => return "[object EventTarget]", .window => return "[object Window]", .xhr => return "[object XMLHttpRequestEventTarget]", .abort_signal => return "[object AbortSignal]", .media_query_list => return "[object MediaQueryList]", .message_port => return "[object MessagePort]", .text_track_cue => return "[object TextTrackCue]", .navigation => return "[object Navigation]", .screen => return "[object Screen]", .screen_orientation => return "[object ScreenOrientation]", .visual_viewport => return "[object VisualViewport]", .file_reader => return "[object FileReader]", .font_face_set => return "[object FontFaceSet]", }; } pub const JsApi = struct { pub const bridge = js.Bridge(EventTarget); pub const Meta = struct { pub const name = "EventTarget"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const constructor = bridge.constructor(EventTarget.init, .{}); pub const dispatchEvent = bridge.function(EventTarget.dispatchEvent, .{ .dom_exception = true }); pub const addEventListener = bridge.function(EventTarget.addEventListener, .{}); pub const removeEventListener = bridge.function(EventTarget.removeEventListener, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: EventTarget" { // we create thousands of these per page. Nothing should bloat it. try testing.expectEqual(16, @sizeOf(EventTarget)); try testing.htmlRunner("events.html", .{}); } ================================================ FILE: src/browser/webapi/File.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Blob = @import("Blob.zig"); const File = @This(); /// `File` inherits `Blob`. _proto: *Blob, // TODO: Implement File API. pub fn init(page: *Page) !*File { const arena = try page.getArena(.{ .debug = "File" }); errdefer page.releaseArena(arena); return page._factory.blob(arena, File{ ._proto = undefined }); } pub fn deinit(self: *File, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub const JsApi = struct { pub const bridge = js.Bridge(File); pub const Meta = struct { pub const name = "File"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(File.deinit); }; pub const constructor = bridge.constructor(File.init, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: File" { try testing.htmlRunner("file.html", .{}); } ================================================ FILE: src/browser/webapi/FileList.zig ================================================ const js = @import("../js/js.zig"); const FileList = @This(); /// Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, pub fn getLength(_: *const FileList) u32 { return 0; } pub fn item(_: *const FileList, _: u32) ?*@import("File.zig") { return null; } pub const JsApi = struct { pub const bridge = js.Bridge(FileList); pub const Meta = struct { pub const name = "FileList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const length = bridge.accessor(FileList.getLength, null, .{}); pub const item = bridge.function(FileList.item, .{}); }; ================================================ FILE: src/browser/webapi/FileReader.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const EventTarget = @import("EventTarget.zig"); const ProgressEvent = @import("event/ProgressEvent.zig"); const Blob = @import("Blob.zig"); const Allocator = std.mem.Allocator; /// https://w3c.github.io/FileAPI/#dfn-filereader /// https://developer.mozilla.org/en-US/docs/Web/API/FileReader const FileReader = @This(); _page: *Page, _proto: *EventTarget, _arena: Allocator, _ready_state: ReadyState = .empty, _result: ?Result = null, _error: ?[]const u8 = null, _on_abort: ?js.Function.Temp = null, _on_error: ?js.Function.Temp = null, _on_load: ?js.Function.Temp = null, _on_load_end: ?js.Function.Temp = null, _on_load_start: ?js.Function.Temp = null, _on_progress: ?js.Function.Temp = null, _aborted: bool = false, const ReadyState = enum(u8) { empty = 0, loading = 1, done = 2, }; const Result = union(enum) { string: []const u8, arraybuffer: js.ArrayBuffer, }; pub fn init(page: *Page) !*FileReader { const arena = try page.getArena(.{ .debug = "FileReader" }); errdefer page.releaseArena(arena); return page._factory.eventTargetWithAllocator(arena, FileReader{ ._page = page, ._arena = arena, ._proto = undefined, }); } pub fn deinit(self: *FileReader, _: bool, session: *Session) void { if (self._on_abort) |func| func.release(); if (self._on_error) |func| func.release(); if (self._on_load) |func| func.release(); if (self._on_load_end) |func| func.release(); if (self._on_load_start) |func| func.release(); if (self._on_progress) |func| func.release(); session.releaseArena(self._arena); } fn asEventTarget(self: *FileReader) *EventTarget { return self._proto; } pub fn getOnAbort(self: *const FileReader) ?js.Function.Temp { return self._on_abort; } pub fn setOnAbort(self: *FileReader, cb: ?js.Function.Temp) !void { self._on_abort = cb; } pub fn getOnError(self: *const FileReader) ?js.Function.Temp { return self._on_error; } pub fn setOnError(self: *FileReader, cb: ?js.Function.Temp) !void { self._on_error = cb; } pub fn getOnLoad(self: *const FileReader) ?js.Function.Temp { return self._on_load; } pub fn setOnLoad(self: *FileReader, cb: ?js.Function.Temp) !void { self._on_load = cb; } pub fn getOnLoadEnd(self: *const FileReader) ?js.Function.Temp { return self._on_load_end; } pub fn setOnLoadEnd(self: *FileReader, cb: ?js.Function.Temp) !void { self._on_load_end = cb; } pub fn getOnLoadStart(self: *const FileReader) ?js.Function.Temp { return self._on_load_start; } pub fn setOnLoadStart(self: *FileReader, cb: ?js.Function.Temp) !void { self._on_load_start = cb; } pub fn getOnProgress(self: *const FileReader) ?js.Function.Temp { return self._on_progress; } pub fn setOnProgress(self: *FileReader, cb: ?js.Function.Temp) !void { self._on_progress = cb; } pub fn getReadyState(self: *const FileReader) u8 { return @intFromEnum(self._ready_state); } pub fn getResult(self: *const FileReader) ?Result { return self._result; } pub fn getError(self: *const FileReader) ?[]const u8 { return self._error; } pub fn readAsArrayBuffer(self: *FileReader, blob: *Blob) !void { try self.readInternal(blob, .arraybuffer); } pub fn readAsBinaryString(self: *FileReader, blob: *Blob) !void { try self.readInternal(blob, .binary_string); } pub fn readAsText(self: *FileReader, blob: *Blob, encoding_: ?[]const u8) !void { _ = encoding_; // TODO: Handle encoding properly try self.readInternal(blob, .text); } pub fn readAsDataURL(self: *FileReader, blob: *Blob) !void { try self.readInternal(blob, .data_url); } const ReadType = enum { arraybuffer, binary_string, text, data_url, }; fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void { if (self._ready_state == .loading) { return error.InvalidStateError; } // Reset state self._ready_state = .loading; self._result = null; self._error = null; self._aborted = false; const page = self._page; try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, page); if (self._aborted) { return; } // Perform the read (synchronous since data is in memory) const data = blob._slice; const size = data.len; try self.dispatch(.progress, .{ .loaded = size, .total = size }, page); if (self._aborted) { return; } // Process the data based on read type self._result = switch (read_type) { .arraybuffer => .{ .arraybuffer = .{ .values = data } }, .binary_string => .{ .string = data }, .text => .{ .string = data }, .data_url => blk: { // Create data URL with base64 encoding const mime = if (blob._mime.len > 0) blob._mime else "application/octet-stream"; const data_url = try encodeDataURL(self._arena, mime, data); break :blk .{ .string = data_url }; }, }; self._ready_state = .done; try self.dispatch(.load, .{ .loaded = size, .total = size }, page); try self.dispatch(.load_end, .{ .loaded = size, .total = size }, page); } pub fn abort(self: *FileReader) !void { if (self._ready_state != .loading) { return; } self._aborted = true; self._ready_state = .done; self._result = null; const page = self._page; try self.dispatch(.abort, null, page); try self.dispatch(.load_end, null, page); } fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, page: *Page) !void { const field, const typ = comptime blk: { break :blk switch (event_type) { .abort => .{ "_on_abort", "abort" }, .err => .{ "_on_error", "error" }, .load => .{ "_on_load", "load" }, .load_end => .{ "_on_load_end", "loadend" }, .load_start => .{ "_on_load_start", "loadstart" }, .progress => .{ "_on_progress", "progress" }, }; }; const progress = progress_ orelse Progress{}; const event = (try ProgressEvent.initTrusted( comptime .wrap(typ), .{ .total = progress.total, .loaded = progress.loaded }, page, )).asEvent(); return page._event_manager.dispatchDirect( self.asEventTarget(), event, @field(self, field), .{ .context = "FileReader " ++ typ }, ); } const DispatchType = enum { abort, err, load, load_end, load_start, progress, }; const Progress = struct { loaded: usize = 0, total: usize = 0, }; /// Encodes binary data as a data URL with base64 encoding. /// Format: data:[][;base64], fn encodeDataURL(arena: Allocator, mime: []const u8, data: []const u8) ![]const u8 { const base64 = std.base64.standard.Encoder; // Calculate size needed for base64 encoding const encoded_size = base64.calcSize(data.len); // Allocate buffer for the full data URL // Format: "data:" + mime + ";base64," + encoded_data const prefix = "data:"; const suffix = ";base64,"; const total_size = prefix.len + mime.len + suffix.len + encoded_size; var pos: usize = 0; const buf = try arena.alloc(u8, total_size); @memcpy(buf[pos..][0..prefix.len], prefix); pos += prefix.len; @memcpy(buf[pos..][0..mime.len], mime); pos += mime.len; @memcpy(buf[pos..][0..suffix.len], suffix); pos += suffix.len; _ = base64.encode(buf[pos..], data); return buf; } pub const JsApi = struct { pub const bridge = js.Bridge(FileReader); pub const Meta = struct { pub const name = "FileReader"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(FileReader.deinit); }; pub const constructor = bridge.constructor(FileReader.init, .{}); // State constants pub const EMPTY = bridge.property(@intFromEnum(FileReader.ReadyState.empty), .{ .template = true }); pub const LOADING = bridge.property(@intFromEnum(FileReader.ReadyState.loading), .{ .template = true }); pub const DONE = bridge.property(@intFromEnum(FileReader.ReadyState.done), .{ .template = true }); // Properties pub const readyState = bridge.accessor(FileReader.getReadyState, null, .{}); pub const result = bridge.accessor(FileReader.getResult, null, .{}); pub const @"error" = bridge.accessor(FileReader.getError, null, .{}); // Event handlers pub const onabort = bridge.accessor(FileReader.getOnAbort, FileReader.setOnAbort, .{}); pub const onerror = bridge.accessor(FileReader.getOnError, FileReader.setOnError, .{}); pub const onload = bridge.accessor(FileReader.getOnLoad, FileReader.setOnLoad, .{}); pub const onloadend = bridge.accessor(FileReader.getOnLoadEnd, FileReader.setOnLoadEnd, .{}); pub const onloadstart = bridge.accessor(FileReader.getOnLoadStart, FileReader.setOnLoadStart, .{}); pub const onprogress = bridge.accessor(FileReader.getOnProgress, FileReader.setOnProgress, .{}); // Methods pub const readAsArrayBuffer = bridge.function(FileReader.readAsArrayBuffer, .{ .dom_exception = true }); pub const readAsBinaryString = bridge.function(FileReader.readAsBinaryString, .{ .dom_exception = true }); pub const readAsText = bridge.function(FileReader.readAsText, .{ .dom_exception = true }); pub const readAsDataURL = bridge.function(FileReader.readAsDataURL, .{ .dom_exception = true }); pub const abort = bridge.function(FileReader.abort, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: FileReader" { try testing.htmlRunner("file_reader.html", .{}); } ================================================ FILE: src/browser/webapi/HTMLDocument.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const js = @import("../js/js.zig"); const String = @import("../../string.zig").String; const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const Document = @import("Document.zig"); const Element = @import("Element.zig"); const DocumentType = @import("DocumentType.zig"); const collections = @import("collections.zig"); const HTMLDocument = @This(); _proto: *Document, _document_type: ?*DocumentType = null, pub fn asDocument(self: *HTMLDocument) *Document { return self._proto; } pub fn asNode(self: *HTMLDocument) *Node { return self._proto.asNode(); } pub fn asEventTarget(self: *HTMLDocument) *@import("EventTarget.zig") { return self._proto.asEventTarget(); } // HTML-specific accessors pub fn getHead(self: *HTMLDocument) ?*Element.Html.Head { const doc_el = self._proto.getDocumentElement() orelse return null; var child = doc_el.asNode().firstChild(); while (child) |node| { if (node.is(Element.Html.Head)) |head| { return head; } child = node.nextSibling(); } return null; } pub fn getBody(self: *HTMLDocument) ?*Element.Html.Body { const doc_el = self._proto.getDocumentElement() orelse return null; var child = doc_el.asNode().firstChild(); while (child) |node| { if (node.is(Element.Html.Body)) |body| { return body; } child = node.nextSibling(); } return null; } pub fn getTitle(self: *HTMLDocument, page: *Page) ![]const u8 { // Search the entire document for the first element const root = self._proto.getDocumentElement() orelse return ""; const title_element = blk: { var walker = @import("TreeWalker.zig").Full.init(root.asNode(), .{}); while (walker.next()) |node| { if (node.is(Element.Html.Title)) |title| { break :blk title; } } return ""; }; var buf = std.Io.Writer.Allocating.init(page.call_arena); try title_element.asNode().getTextContent(&buf.writer); const text = buf.written(); if (text.len == 0) { return ""; } var started = false; var in_whitespace = false; var result: std.ArrayList(u8) = .empty; try result.ensureTotalCapacity(page.call_arena, text.len); for (text) |c| { const is_ascii_ws = c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '\x0C'; if (is_ascii_ws) { if (started) { in_whitespace = true; } } else { if (in_whitespace) { result.appendAssumeCapacity(' '); in_whitespace = false; } result.appendAssumeCapacity(c); started = true; } } return result.items; } pub fn setTitle(self: *HTMLDocument, title: []const u8, page: *Page) !void { const head = self.getHead() orelse return; // Find existing title element in head var it = head.asNode().childrenIterator(); while (it.next()) |node| { if (node.is(Element.Html.Title)) |title_element| { // Replace children, but don't create text node for empty string if (title.len == 0) { return title_element.asElement().replaceChildren(&.{}, page); } else { return title_element.asElement().replaceChildren(&.{.{ .text = title }}, page); } } } // No title element found, create one const title_node = try page.createElementNS(.html, "title", null); const title_element = title_node.as(Element); // Only add text if non-empty if (title.len > 0) { try title_element.replaceChildren(&.{.{ .text = title }}, page); } _ = try head.asNode().appendChild(title_node, page); } pub fn getImages(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { return collections.NodeLive(.tag).init(self.asNode(), .img, page); } pub fn getScripts(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { return collections.NodeLive(.tag).init(self.asNode(), .script, page); } pub fn getLinks(self: *HTMLDocument, page: *Page) !collections.NodeLive(.links) { return collections.NodeLive(.links).init(self.asNode(), {}, page); } pub fn getAnchors(self: *HTMLDocument, page: *Page) !collections.NodeLive(.anchors) { return collections.NodeLive(.anchors).init(self.asNode(), {}, page); } pub fn getForms(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { return collections.NodeLive(.tag).init(self.asNode(), .form, page); } pub fn getEmbeds(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) { return collections.NodeLive(.tag).init(self.asNode(), .embed, page); } pub fn getApplets(_: *const HTMLDocument) collections.HTMLCollection { return .{ ._data = .empty }; } pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script { return self._proto._current_script; } pub fn getLocation(self: *const HTMLDocument) ?*@import("Location.zig") { return self._proto._location; } pub fn setLocation(self: *HTMLDocument, url: [:0]const u8, page: *Page) !void { return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._proto._page }); } pub fn getAll(self: *HTMLDocument, page: *Page) !*collections.HTMLAllCollection { return page._factory.create(collections.HTMLAllCollection.init(self.asNode(), page)); } pub fn getCookie(_: *HTMLDocument, page: *Page) ![]const u8 { var buf: std.ArrayList(u8) = .empty; try page._session.cookie_jar.forRequest(page.url, buf.writer(page.call_arena), .{ .is_http = false, .is_navigation = true, }); return buf.items; } pub fn setCookie(_: *HTMLDocument, cookie_str: []const u8, page: *Page) ![]const u8 { // we use the cookie jar's allocator to parse the cookie because it // outlives the page's arena. const Cookie = @import("storage/Cookie.zig"); const c = Cookie.parse(page._session.cookie_jar.allocator, page.url, cookie_str) catch { // Invalid cookies should be silently ignored, not throw errors return ""; }; errdefer c.deinit(); if (c.http_only) { c.deinit(); return ""; // HttpOnly cookies cannot be set from JS } try page._session.cookie_jar.add(c, std.time.timestamp()); return cookie_str; } pub fn getDocType(self: *HTMLDocument, page: *Page) !*DocumentType { if (self._document_type) |dt| { return dt; } var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{}); while (tw.next()) |node| { if (node._type == .document_type) { self._document_type = node.as(DocumentType); return self._document_type.?; } } self._document_type = try page._factory.node(DocumentType{ ._proto = undefined, ._name = "html", ._public_id = "", ._system_id = "", }); return self._document_type.?; } pub const JsApi = struct { pub const bridge = js.Bridge(HTMLDocument); pub const Meta = struct { pub const name = "HTMLDocument"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(_constructor, .{}); fn _constructor(page: *Page) !*HTMLDocument { return page._factory.document(HTMLDocument{ ._proto = undefined, }); } pub const head = bridge.accessor(HTMLDocument.getHead, null, .{}); pub const body = bridge.accessor(HTMLDocument.getBody, null, .{}); pub const title = bridge.accessor(HTMLDocument.getTitle, HTMLDocument.setTitle, .{}); pub const images = bridge.accessor(HTMLDocument.getImages, null, .{}); pub const scripts = bridge.accessor(HTMLDocument.getScripts, null, .{}); pub const links = bridge.accessor(HTMLDocument.getLinks, null, .{}); pub const anchors = bridge.accessor(HTMLDocument.getAnchors, null, .{}); pub const forms = bridge.accessor(HTMLDocument.getForms, null, .{}); pub const embeds = bridge.accessor(HTMLDocument.getEmbeds, null, .{}); pub const applets = bridge.accessor(HTMLDocument.getApplets, null, .{}); pub const plugins = bridge.accessor(HTMLDocument.getEmbeds, null, .{}); pub const currentScript = bridge.accessor(HTMLDocument.getCurrentScript, null, .{}); pub const location = bridge.accessor(HTMLDocument.getLocation, HTMLDocument.setLocation, .{}); pub const all = bridge.accessor(HTMLDocument.getAll, null, .{}); pub const cookie = bridge.accessor(HTMLDocument.getCookie, HTMLDocument.setCookie, .{}); pub const doctype = bridge.accessor(HTMLDocument.getDocType, null, .{}); }; ================================================ FILE: src/browser/webapi/History.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const PopStateEvent = @import("event/PopStateEvent.zig"); const History = @This(); const ScrollRestoration = enum { auto, manual }; _scroll_restoration: ScrollRestoration = .auto, pub fn getLength(_: *const History, page: *Page) u32 { return @intCast(page._session.navigation._entries.items.len); } pub fn getState(_: *const History, page: *Page) !?js.Value { if (page._session.navigation.getCurrentEntry()._state.value) |state| { const value = try page.js.local.?.parseJSON(state); return value; } else return null; } pub fn getScrollRestoration(self: *History) []const u8 { return @tagName(self._scroll_restoration); } pub fn setScrollRestoration(self: *History, str: []const u8) void { if (std.meta.stringToEnum(ScrollRestoration, str)) |sr| { self._scroll_restoration = sr; } } pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { const arena = page._session.arena; const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url); const json = state.toJson(arena) catch return error.DataClone; _ = try page._session.navigation.pushEntry(url, .{ .source = .history, .value = json }, page, true); } pub fn replaceState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { const arena = page._session.arena; const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url); const json = state.toJson(arena) catch return error.DataClone; _ = try page._session.navigation.replaceEntry(url, .{ .source = .history, .value = json }, page, true); } fn goInner(delta: i32, page: *Page) !void { // 0 behaves the same as no argument, both reloading the page. const current = page._session.navigation._index; const index_s: i64 = @intCast(@as(i64, @intCast(current)) + @as(i64, @intCast(delta))); if (index_s < 0 or index_s > page._session.navigation._entries.items.len - 1) { return; } const index = @as(usize, @intCast(index_s)); const entry = page._session.navigation._entries.items[index]; if (entry._url) |url| { if (try page.isSameOrigin(url)) { const target = page.window.asEventTarget(); if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) { const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent(); try page._event_manager.dispatchDirect(target, event, page.window._on_popstate, .{ .context = "Pop State" }); } } } _ = try page._session.navigation.navigateInner(entry._url, .{ .traverse = index }, page); } pub fn back(_: *History, page: *Page) !void { try goInner(-1, page); } pub fn forward(_: *History, page: *Page) !void { try goInner(1, page); } pub fn go(_: *History, delta: ?i32, page: *Page) !void { try goInner(delta orelse 0, page); } pub const JsApi = struct { pub const bridge = js.Bridge(History); pub const Meta = struct { pub const name = "History"; pub var class_id: bridge.ClassId = 0; pub const prototype_chain = bridge.prototypeChain(); }; pub const length = bridge.accessor(History.getLength, null, .{}); pub const scrollRestoration = bridge.accessor(History.getScrollRestoration, History.setScrollRestoration, .{}); pub const state = bridge.accessor(History.getState, null, .{}); pub const pushState = bridge.function(History.pushState, .{}); pub const replaceState = bridge.function(History.replaceState, .{}); pub const back = bridge.function(History.back, .{}); pub const forward = bridge.function(History.forward, .{}); pub const go = bridge.function(History.go, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: History" { try testing.htmlRunner("history.html", .{}); } ================================================ FILE: src/browser/webapi/IdleDeadline.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const IdleDeadline = @This(); // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, pub fn init() IdleDeadline { return .{}; } pub fn timeRemaining(_: *const IdleDeadline) f64 { // Return a fixed 50ms. // This allows idle callbacks to perform work without complex // timing infrastructure. return 50.0; } pub const JsApi = struct { const js = @import("../js/js.zig"); pub const bridge = js.Bridge(IdleDeadline); pub const Meta = struct { pub const name = "IdleDeadline"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const timeRemaining = bridge.function(IdleDeadline.timeRemaining, .{}); pub const didTimeout = bridge.property(false, .{ .template = false }); }; ================================================ FILE: src/browser/webapi/ImageData.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../string.zig").String; const log = @import("../../log.zig"); const js = @import("../js/js.zig"); const color = @import("../color.zig"); const Page = @import("../Page.zig"); /// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData const ImageData = @This(); _width: u32, _height: u32, _data: js.ArrayBufferRef(.uint8_clamped).Global, pub const ConstructorSettings = struct { /// Specifies the color space of the image data. /// Can be set to "srgb" for the sRGB color space or "display-p3" for the display-p3 color space. colorSpace: String = .wrap("srgb"), /// Specifies the pixel format. /// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createImageData#pixelformat pixelFormat: String = .wrap("rgba-unorm8"), }; /// This has many constructors: /// /// ```js /// new ImageData(width, height) /// new ImageData(width, height, settings) /// /// new ImageData(dataArray, width) /// new ImageData(dataArray, width, height) /// new ImageData(dataArray, width, height, settings) /// ``` /// /// We currently support only the first 2. pub fn init( width: u32, height: u32, maybe_settings: ?ConstructorSettings, page: *Page, ) !*ImageData { // Though arguments are unsigned long, these are capped to max. i32 on Chrome. // https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/html/canvas/image_data.cc#L61 const max_i32 = std.math.maxInt(i32); if (width == 0 or width > max_i32 or height == 0 or height > max_i32) { return error.IndexSizeError; } const settings: ConstructorSettings = maybe_settings orelse .{}; if (settings.colorSpace.eql(comptime .wrap("srgb")) == false) { return error.TypeError; } if (settings.pixelFormat.eql(comptime .wrap("rgba-unorm8")) == false) { return error.TypeError; } var size, var overflown = @mulWithOverflow(width, height); if (overflown == 1) return error.IndexSizeError; size, overflown = @mulWithOverflow(size, 4); if (overflown == 1) return error.IndexSizeError; return page._factory.create(ImageData{ ._width = width, ._height = height, ._data = try page.js.local.?.createTypedArray(.uint8_clamped, size).persist(), }); } pub fn getWidth(self: *const ImageData) u32 { return self._width; } pub fn getHeight(self: *const ImageData) u32 { return self._height; } pub fn getData(self: *const ImageData) js.ArrayBufferRef(.uint8_clamped).Global { return self._data; } pub const JsApi = struct { pub const bridge = js.Bridge(ImageData); pub const Meta = struct { pub const name = "ImageData"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(ImageData.init, .{ .dom_exception = true }); pub const colorSpace = bridge.property("srgb", .{ .template = false, .readonly = true }); pub const pixelFormat = bridge.property("rgba-unorm8", .{ .template = false, .readonly = true }); pub const data = bridge.accessor(ImageData.getData, null, .{}); pub const width = bridge.accessor(ImageData.getWidth, null, .{}); pub const height = bridge.accessor(ImageData.getHeight, null, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: ImageData" { try testing.htmlRunner("image_data.html", .{}); } ================================================ FILE: src/browser/webapi/IntersectionObserver.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const log = @import("../../log.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const Allocator = std.mem.Allocator; const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Element = @import("Element.zig"); const DOMRect = @import("DOMRect.zig"); pub fn registerTypes() []const type { return &.{ IntersectionObserver, IntersectionObserverEntry, }; } const IntersectionObserver = @This(); _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(*Element) = .{}, _root: ?*Element = null, _root_margin: []const u8 = "0px", _threshold: []const f64 = &.{0.0}, _pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{}, _previous_states: std.AutoHashMapUnmanaged(*Element, bool) = .{}, // Shared zero DOMRect to avoid repeated allocations for non-intersecting elements var zero_rect: DOMRect = .{ ._x = 0.0, ._y = 0.0, ._width = 0.0, ._height = 0.0, }; pub const ObserverInit = struct { root: ?*Element = null, rootMargin: ?[]const u8 = null, threshold: Threshold = .{ .scalar = 0.0 }, const Threshold = union(enum) { scalar: f64, array: []const f64, }; }; pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*IntersectionObserver { const arena = try page.getArena(.{ .debug = "IntersectionObserver" }); errdefer page.releaseArena(arena); const opts = options orelse ObserverInit{}; const root_margin = if (opts.rootMargin) |rm| try arena.dupe(u8, rm) else "0px"; const threshold = switch (opts.threshold) { .scalar => |s| blk: { const arr = try arena.alloc(f64, 1); arr[0] = s; break :blk arr; }, .array => |arr| try arena.dupe(f64, arr), }; const self = try arena.create(IntersectionObserver); self.* = .{ ._arena = arena, ._callback = callback, ._root = opts.root, ._root_margin = root_margin, ._threshold = threshold, }; return self; } pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void { if (shutdown) { self._callback.release(); session.releaseArena(self._arena); } else if (comptime IS_DEBUG) { std.debug.assert(false); } } pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { // Check if already observing this target for (self._observing.items) |elem| { if (elem == target) { return; } } // Register with page if this is our first observation if (self._observing.items.len == 0) { try page.registerIntersectionObserver(self); } try self._observing.append(self._arena, target); // Don't initialize previous state yet - let checkIntersection do it // This ensures we get an entry on first observation // Check intersection for this new target and schedule delivery try self.checkIntersection(target, page); if (self._pending_entries.items.len > 0) { try page.scheduleIntersectionDelivery(); } } pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) void { for (self._observing.items, 0..) |elem, i| { if (elem == target) { _ = self._observing.swapRemove(i); _ = self._previous_states.remove(target); // Remove any pending entries for this target var j: usize = 0; while (j < self._pending_entries.items.len) { if (self._pending_entries.items[j]._target == target) { const entry = self._pending_entries.swapRemove(j); entry.deinit(false, page._session); } else { j += 1; } } break; } } } pub fn disconnect(self: *IntersectionObserver, page: *Page) void { self._previous_states.clearRetainingCapacity(); for (self._pending_entries.items) |entry| { entry.deinit(false, page._session); } self._pending_entries.clearRetainingCapacity(); self._observing.clearRetainingCapacity(); page.unregisterIntersectionObserver(self); } pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry { const entries = try page.call_arena.dupe(*IntersectionObserverEntry, self._pending_entries.items); self._pending_entries.clearRetainingCapacity(); return entries; } fn calculateIntersection( self: *IntersectionObserver, target: *Element, page: *Page, ) !IntersectionData { const target_rect = target.getBoundingClientRect(page); // Use root element's rect or viewport (simplified: assume 1920x1080) const root_rect = if (self._root) |root| root.getBoundingClientRect(page) else // Simplified viewport - assume 1920x1080 for now DOMRect{ ._x = 0.0, ._y = 0.0, ._width = 1920.0, ._height = 1080.0, }; // For a headless browser without real layout, we treat all elements as fully visible. // This avoids fingerprinting issues (massive viewports) and matches the behavior // scripts expect when querying element visibility. // However, elements without a parent cannot intersect (they have no containing block). const has_parent = target.asNode().parentNode() != null; const is_intersecting = has_parent; const intersection_ratio: f64 = if (has_parent) 1.0 else 0.0; // Intersection rect is the same as the target rect if visible, otherwise zero rect const intersection_rect = if (has_parent) target_rect else zero_rect; return .{ .is_intersecting = is_intersecting, .intersection_ratio = intersection_ratio, .intersection_rect = intersection_rect, .bounding_client_rect = target_rect, .root_bounds = root_rect, }; } const IntersectionData = struct { is_intersecting: bool, intersection_ratio: f64, intersection_rect: DOMRect, bounding_client_rect: DOMRect, root_bounds: DOMRect, }; fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool { for (self._threshold) |threshold| { if (ratio >= threshold) { return true; } } return false; } fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page) !void { const data = try self.calculateIntersection(target, page); const was_intersecting_opt = self._previous_states.get(target); const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio); // Create entry if: // 1. First time observing this target AND it's intersecting // 2. State changed const should_report = (was_intersecting_opt == null and is_now_intersecting) or (was_intersecting_opt != null and was_intersecting_opt.? != is_now_intersecting); if (should_report) { const arena = try page.getArena(.{ .debug = "IntersectionObserverEntry" }); errdefer page.releaseArena(arena); const entry = try arena.create(IntersectionObserverEntry); entry.* = .{ ._arena = arena, ._target = target, ._time = page.window._performance.now(), ._is_intersecting = is_now_intersecting, ._root_bounds = try page._factory.create(data.root_bounds), ._intersection_rect = try page._factory.create(data.intersection_rect), ._bounding_client_rect = try page._factory.create(data.bounding_client_rect), ._intersection_ratio = data.intersection_ratio, }; try self._pending_entries.append(self._arena, entry); } // Always update the previous state, even if we didn't report // This ensures we can detect state changes on subsequent checks try self._previous_states.put(self._arena, target, is_now_intersecting); } pub fn checkIntersections(self: *IntersectionObserver, page: *Page) !void { if (self._observing.items.len == 0) { return; } for (self._observing.items) |target| { try self.checkIntersection(target, page); } if (self._pending_entries.items.len > 0) { try page.scheduleIntersectionDelivery(); } } pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void { if (self._pending_entries.items.len == 0) { return; } const entries = try self.takeRecords(page); var caught: js.TryCatch.Caught = undefined; var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); ls.toLocal(self._callback).tryCall(void, .{ entries, self }, &caught) catch |err| { log.err(.page, "IntsctObserver.deliverEntries", .{ .err = err, .caught = caught }); return err; }; } pub const IntersectionObserverEntry = struct { _arena: Allocator, _time: f64, _target: *Element, _bounding_client_rect: *DOMRect, _intersection_rect: *DOMRect, _root_bounds: *DOMRect, _intersection_ratio: f64, _is_intersecting: bool, pub fn deinit(self: *IntersectionObserverEntry, _: bool, session: *Session) void { session.releaseArena(self._arena); } pub fn getTarget(self: *const IntersectionObserverEntry) *Element { return self._target; } pub fn getTime(self: *const IntersectionObserverEntry) f64 { return self._time; } pub fn getBoundingClientRect(self: *const IntersectionObserverEntry) *DOMRect { return self._bounding_client_rect; } pub fn getIntersectionRect(self: *const IntersectionObserverEntry) *DOMRect { return self._intersection_rect; } pub fn getRootBounds(self: *const IntersectionObserverEntry) ?*DOMRect { return self._root_bounds; } pub fn getIntersectionRatio(self: *const IntersectionObserverEntry) f64 { return self._intersection_ratio; } pub fn getIsIntersecting(self: *const IntersectionObserverEntry) bool { return self._is_intersecting; } pub const JsApi = struct { pub const bridge = js.Bridge(IntersectionObserverEntry); pub const Meta = struct { pub const name = "IntersectionObserverEntry"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(IntersectionObserverEntry.deinit); }; pub const target = bridge.accessor(IntersectionObserverEntry.getTarget, null, .{}); pub const time = bridge.accessor(IntersectionObserverEntry.getTime, null, .{}); pub const boundingClientRect = bridge.accessor(IntersectionObserverEntry.getBoundingClientRect, null, .{}); pub const intersectionRect = bridge.accessor(IntersectionObserverEntry.getIntersectionRect, null, .{}); pub const rootBounds = bridge.accessor(IntersectionObserverEntry.getRootBounds, null, .{}); pub const intersectionRatio = bridge.accessor(IntersectionObserverEntry.getIntersectionRatio, null, .{}); pub const isIntersecting = bridge.accessor(IntersectionObserverEntry.getIsIntersecting, null, .{}); }; }; pub const JsApi = struct { pub const bridge = js.Bridge(IntersectionObserver); pub const Meta = struct { pub const name = "IntersectionObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const finalizer = bridge.finalizer(IntersectionObserver.deinit); }; pub const constructor = bridge.constructor(init, .{}); pub const observe = bridge.function(IntersectionObserver.observe, .{}); pub const unobserve = bridge.function(IntersectionObserver.unobserve, .{}); pub const disconnect = bridge.function(IntersectionObserver.disconnect, .{}); pub const takeRecords = bridge.function(IntersectionObserver.takeRecords, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: IntersectionObserver" { try testing.htmlRunner("intersection_observer", .{}); } ================================================ FILE: src/browser/webapi/KeyValueList.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Allocator = std.mem.Allocator; pub fn registerTypes() []const type { return &.{ KeyIterator, ValueIterator, EntryIterator, }; } const Normalizer = *const fn ([]const u8, *Page) []const u8; pub const Entry = struct { name: String, value: String, pub fn format(self: Entry, writer: *std.Io.Writer) !void { return writer.print("{f}: {f}", .{ self.name, self.value }); } }; pub const KeyValueList = @This(); _entries: std.ArrayList(Entry) = .empty, pub const empty: KeyValueList = .{ ._entries = .empty, }; pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList { var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, original.len()); for (original._entries.items) |entry| { try list.appendAssumeCapacity(arena, entry.name.str(), entry.value.str()); } return list; } pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { var it = try js_obj.nameIterator(); var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, it.count); while (try it.next()) |name| { const js_value = try js_obj.get(name); const normalized = if (comptime normalizer) |n| n(name, page) else name; list._entries.appendAssumeCapacity(.{ .name = try String.init(arena, normalized, .{}), .value = try js_value.toSSOWithAlloc(arena), }); } return list; } pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, kvs.len); for (kvs) |pair| { const normalized = if (comptime normalizer) |n| n(pair[0], page) else pair[0]; list._entries.appendAssumeCapacity(.{ .name = try String.init(arena, normalized, .{}), .value = try String.init(arena, pair[1], .{}), }); } return list; } pub fn init() KeyValueList { return .{}; } pub fn ensureTotalCapacity(self: *KeyValueList, allocator: Allocator, n: usize) !void { return self._entries.ensureTotalCapacity(allocator, n); } pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 { for (self._entries.items) |*entry| { if (entry.name.eqlSlice(name)) { return entry.value.str(); } } return null; } pub fn getAll(self: *const KeyValueList, name: []const u8, page: *Page) ![]const []const u8 { const arena = page.call_arena; var arr: std.ArrayList([]const u8) = .empty; for (self._entries.items) |*entry| { if (entry.name.eqlSlice(name)) { try arr.append(arena, entry.value.str()); } } return arr.items; } pub fn has(self: *const KeyValueList, name: []const u8) bool { for (self._entries.items) |*entry| { if (entry.name.eqlSlice(name)) { return true; } } return false; } pub fn append(self: *KeyValueList, allocator: Allocator, name: []const u8, value: []const u8) !void { try self._entries.append(allocator, .{ .name = try String.init(allocator, name, .{}), .value = try String.init(allocator, value, .{}), }); } pub fn appendAssumeCapacity(self: *KeyValueList, allocator: Allocator, name: []const u8, value: []const u8) !void { self._entries.appendAssumeCapacity(.{ .name = try String.init(allocator, name, .{}), .value = try String.init(allocator, value, .{}), }); } pub fn delete(self: *KeyValueList, name: []const u8, value: ?[]const u8) void { var i: usize = 0; while (i < self._entries.items.len) { const entry = self._entries.items[i]; if (entry.name.eqlSlice(name)) { if (value == null or entry.value.eqlSlice(value.?)) { _ = self._entries.swapRemove(i); continue; } } i += 1; } } pub fn set(self: *KeyValueList, allocator: Allocator, name: []const u8, value: []const u8) !void { self.delete(name, null); try self.append(allocator, name, value); } pub fn len(self: *const KeyValueList) usize { return self._entries.items.len; } pub fn items(self: *const KeyValueList) []const Entry { return self._entries.items; } const URLEncodeMode = enum { form, query, }; pub fn urlEncode(self: *const KeyValueList, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { const entries = self._entries.items; if (entries.len == 0) { return; } try urlEncodeEntry(entries[0], mode, writer); for (entries[1..]) |entry| { try writer.writeByte('&'); try urlEncodeEntry(entry, mode, writer); } } fn urlEncodeEntry(entry: Entry, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { try urlEncodeValue(entry.name.str(), mode, writer); // for a form, for an empty value, we'll do "spice=" // but for a query, we do "spice" if ((comptime mode == .query) and entry.value.len == 0) { return; } try writer.writeByte('='); try urlEncodeValue(entry.value.str(), mode, writer); } fn urlEncodeValue(value: []const u8, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { if (!urlEncodeShouldEscape(value, mode)) { return writer.writeAll(value); } for (value) |b| { if (urlEncodeUnreserved(b, mode)) { try writer.writeByte(b); } else if (b == ' ') { try writer.writeByte('+'); } else if (b >= 0x80) { // Double-encode: treat byte as Latin-1 code point, encode to UTF-8, then percent-encode // For bytes 0x80-0xFF (U+0080 to U+00FF), UTF-8 encoding is 2 bytes: // [0xC0 | (b >> 6), 0x80 | (b & 0x3F)] const byte1 = 0xC0 | (b >> 6); const byte2 = 0x80 | (b & 0x3F); try writer.print("%{X:0>2}%{X:0>2}", .{ byte1, byte2 }); } else { try writer.print("%{X:0>2}", .{b}); } } } fn urlEncodeShouldEscape(value: []const u8, comptime mode: URLEncodeMode) bool { for (value) |b| { if (!urlEncodeUnreserved(b, mode)) { return true; } } return false; } fn urlEncodeUnreserved(b: u8, comptime mode: URLEncodeMode) bool { return switch (b) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '*' => true, '~' => comptime mode == .form, else => false, }; } pub const Iterator = struct { index: u32 = 0, kv: *KeyValueList, // Why? Because whenever an Iterator is created, we need to increment the // RC of what it's iterating. And when the iterator is destroyed, we need // to decrement it. The generic iterator which will wrap this handles that // by using this "list" field. Most things that use the GenericIterator can // just set `list: *ZigCollection`, and everything will work. But KeyValueList // is being composed by various types, so it can't reference those types. // Using *anyopaque here is "dangerous", in that it requires the composer // to pass the right value, which normally would be itself (`*Self`), but // only because (as of now) everyting that uses KeyValueList has no prototype list: *anyopaque, pub const Entry = struct { []const u8, []const u8 }; pub fn next(self: *Iterator, _: *const Page) ?Iterator.Entry { const index = self.index; const entries = self.kv._entries.items; if (index >= entries.len) { return null; } self.index = index + 1; const e = &entries[index]; return .{ e.name.str(), e.value.str() }; } }; pub fn iterator(self: *const KeyValueList) Iterator { return .{ .list = self }; } const GenericIterator = @import("collections/iterator.zig").Entry; pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); pub const EntryIterator = GenericIterator(Iterator, null); ================================================ FILE: src/browser/webapi/Location.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const URL = @import("URL.zig"); const Page = @import("../Page.zig"); const Location = @This(); _url: *URL, pub fn init(raw_url: [:0]const u8, page: *Page) !*Location { const url = try URL.init(raw_url, null, page); return page._factory.create(Location{ ._url = url, }); } pub fn getPathname(self: *const Location) []const u8 { return self._url.getPathname(); } pub fn getProtocol(self: *const Location) []const u8 { return self._url.getProtocol(); } pub fn getHostname(self: *const Location) []const u8 { return self._url.getHostname(); } pub fn getHost(self: *const Location) []const u8 { return self._url.getHost(); } pub fn getPort(self: *const Location) []const u8 { return self._url.getPort(); } pub fn getOrigin(self: *const Location, page: *const Page) ![]const u8 { return self._url.getOrigin(page); } pub fn getSearch(self: *const Location, page: *const Page) ![]const u8 { return self._url.getSearch(page); } pub fn getHash(self: *const Location) []const u8 { return self._url.getHash(); } pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void { const normalized_hash = blk: { if (hash.len == 0) { const old_url = page.url; break :blk if (std.mem.indexOfScalar(u8, old_url, '#')) |index| old_url[0..index] else old_url; } else if (hash[0] == '#') break :blk hash else break :blk try std.fmt.allocPrint(page.call_arena, "#{s}", .{hash}); }; return page.scheduleNavigation(normalized_hash, .{ .reason = .script, .kind = .{ .replace = null }, }, .{ .script = page }); } pub fn assign(_: *const Location, url: [:0]const u8, page: *Page) !void { return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = page }); } pub fn replace(_: *const Location, url: [:0]const u8, page: *Page) !void { return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .replace = null } }, .{ .script = page }); } pub fn reload(_: *const Location, page: *Page) !void { return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .{ .script = page }); } pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { return self._url.toString(page); } pub const JsApi = struct { pub const bridge = js.Bridge(Location); pub const Meta = struct { pub const name = "Location"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const toString = bridge.function(Location.toString, .{}); pub const href = bridge.accessor(Location.toString, setHref, .{}); fn setHref(self: *const Location, url: [:0]const u8, page: *Page) !void { return self.assign(url, page); } pub const search = bridge.accessor(Location.getSearch, null, .{}); pub const hash = bridge.accessor(Location.getHash, Location.setHash, .{}); pub const pathname = bridge.accessor(Location.getPathname, null, .{}); pub const hostname = bridge.accessor(Location.getHostname, null, .{}); pub const host = bridge.accessor(Location.getHost, null, .{}); pub const port = bridge.accessor(Location.getPort, null, .{}); pub const origin = bridge.accessor(Location.getOrigin, null, .{}); pub const protocol = bridge.accessor(Location.getProtocol, null, .{}); pub const assign = bridge.function(Location.assign, .{}); pub const replace = bridge.function(Location.replace, .{}); pub const reload = bridge.function(Location.reload, .{}); }; ================================================ FILE: src/browser/webapi/MessageChannel.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const MessagePort = @import("MessagePort.zig"); const MessageChannel = @This(); _port1: *MessagePort, _port2: *MessagePort, pub fn init(page: *Page) !*MessageChannel { const port1 = try MessagePort.init(page); const port2 = try MessagePort.init(page); MessagePort.entangle(port1, port2); return page._factory.create(MessageChannel{ ._port1 = port1, ._port2 = port2, }); } pub fn getPort1(self: *const MessageChannel) *MessagePort { return self._port1; } pub fn getPort2(self: *const MessageChannel) *MessagePort { return self._port2; } pub const JsApi = struct { pub const bridge = js.Bridge(MessageChannel); pub const Meta = struct { pub const name = "MessageChannel"; pub var class_id: bridge.ClassId = undefined; pub const prototype_chain = bridge.prototypeChain(); }; pub const constructor = bridge.constructor(MessageChannel.init, .{}); pub const port1 = bridge.accessor(MessageChannel.getPort1, null, .{}); pub const port2 = bridge.accessor(MessageChannel.getPort2, null, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: MessageChannel" { try testing.htmlRunner("message_channel.html", .{}); } ================================================ FILE: src/browser/webapi/MessagePort.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const EventTarget = @import("EventTarget.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const MessagePort = @This(); _proto: *EventTarget, _enabled: bool = false, _closed: bool = false, _on_message: ?js.Function.Global = null, _on_message_error: ?js.Function.Global = null, _entangled_port: ?*MessagePort = null, pub fn init(page: *Page) !*MessagePort { return page._factory.eventTarget(MessagePort{ ._proto = undefined, }); } pub fn asEventTarget(self: *MessagePort) *EventTarget { return self._proto; } pub fn entangle(port1: *MessagePort, port2: *MessagePort) void { port1._entangled_port = port2; port2._entangled_port = port1; } pub fn postMessage(self: *MessagePort, message: js.Value.Temp, page: *Page) !void { if (self._closed) { return; } const other = self._entangled_port orelse return; if (other._closed) { return; } // Create callback to deliver message const callback = try page._factory.create(PostMessageCallback{ .page = page, .port = other, .message = message, }); try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "MessagePort.postMessage", .low_priority = false, }); } pub fn start(self: *MessagePort) void { if (self._closed) { return; } self._enabled = true; } pub fn close(self: *MessagePort) void { self._closed = true; // Break entanglement if (self._entangled_port) |other| { other._entangled_port = null; } self._entangled_port = null; } pub fn getOnMessage(self: *const MessagePort) ?js.Function.Global { return self._on_message; } pub fn setOnMessage(self: *MessagePort, cb: ?js.Function.Global) !void { self._on_message = cb; } pub fn getOnMessageError(self: *const MessagePort) ?js.Function.Global { return self._on_message_error; } pub fn setOnMessageError(self: *MessagePort, cb: ?js.Function.Global) !void { self._on_message_error = cb; } const PostMessageCallback = struct { port: *MessagePort, message: js.Value.Temp, page: *Page, fn deinit(self: *PostMessageCallback) void { self.page._factory.destroy(self); } fn run(ctx: *anyopaque) !?u32 { const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); defer self.deinit(); const page = self.page; if (self.port._closed) { return null; } const target = self.port.asEventTarget(); if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) { const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{ .data = self.message, .origin = "", .source = null, }, page) catch |err| { log.err(.dom, "MessagePort.postMessage", .{ .err = err }); return null; }).asEvent(); page._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| { log.err(.dom, "MessagePort.postMessage", .{ .err = err }); }; } return null; } }; pub const JsApi = struct { pub const bridge = js.Bridge(MessagePort); pub const Meta = struct { pub const name = "MessagePort"; pub var class_id: bridge.ClassId = undefined; pub const prototype_chain = bridge.prototypeChain(); }; pub const postMessage = bridge.function(MessagePort.postMessage, .{}); pub const start = bridge.function(MessagePort.start, .{}); pub const close = bridge.function(MessagePort.close, .{}); pub const onmessage = bridge.accessor(MessagePort.getOnMessage, MessagePort.setOnMessage, .{}); pub const onmessageerror = bridge.accessor(MessagePort.getOnMessageError, MessagePort.setOnMessageError, .{}); }; ================================================ FILE: src/browser/webapi/MutationObserver.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); const log = @import("../../log.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const Allocator = std.mem.Allocator; pub fn registerTypes() []const type { return &.{ MutationObserver, MutationRecord, }; } const MutationObserver = @This(); _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(Observing) = .{}, _pending_records: std.ArrayList(*MutationRecord) = .{}, /// Intrusively linked to next element (see Page.zig). node: std.DoublyLinkedList.Node = .{}, const Observing = struct { target: *Node, options: ResolvedOptions, }; /// Internal options with all nullable bools resolved to concrete values. const ResolvedOptions = struct { attributes: bool = false, attributeOldValue: bool = false, childList: bool = false, characterData: bool = false, characterDataOldValue: bool = false, subtree: bool = false, attributeFilter: ?[]const []const u8 = null, }; pub const ObserveOptions = struct { attributes: ?bool = null, attributeOldValue: ?bool = null, childList: bool = false, characterData: ?bool = null, characterDataOldValue: ?bool = null, subtree: bool = false, attributeFilter: ?[]const []const u8 = null, }; pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { const arena = try page.getArena(.{ .debug = "MutationObserver" }); errdefer page.releaseArena(arena); const self = try arena.create(MutationObserver); self.* = .{ ._arena = arena, ._callback = callback, }; return self; } pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { if (shutdown) { self._callback.release(); session.releaseArena(self._arena); } else if (comptime IS_DEBUG) { std.debug.assert(false); } } pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void { const arena = self._arena; // Per spec: if attributeOldValue/attributeFilter present and attributes // not explicitly set, imply attributes=true. Same for characterData. var resolved = options; if (resolved.attributes == null and (resolved.attributeOldValue != null or resolved.attributeFilter != null)) { resolved.attributes = true; } if (resolved.characterData == null and resolved.characterDataOldValue != null) { resolved.characterData = true; } const attributes = resolved.attributes orelse false; const character_data = resolved.characterData orelse false; // Validate: at least one of childList/attributes/characterData must be true if (!resolved.childList and !attributes and !character_data) { return error.TypeError; } // Validate: attributeOldValue/attributeFilter require attributes != false if ((resolved.attributeOldValue orelse false) and !attributes) { return error.TypeError; } if (resolved.attributeFilter != null and !attributes) { return error.TypeError; } // Validate: characterDataOldValue requires characterData != false if ((resolved.characterDataOldValue orelse false) and !character_data) { return error.TypeError; } // Build resolved options with concrete bool values var store_options = ResolvedOptions{ .attributes = attributes, .attributeOldValue = resolved.attributeOldValue orelse false, .childList = resolved.childList, .characterData = character_data, .characterDataOldValue = resolved.characterDataOldValue orelse false, .subtree = resolved.subtree, .attributeFilter = resolved.attributeFilter, }; // Deep copy attributeFilter if present if (options.attributeFilter) |filter| { const filter_copy = try arena.alloc([]const u8, filter.len); for (filter, 0..) |name, i| { filter_copy[i] = try arena.dupe(u8, name); } store_options.attributeFilter = filter_copy; } // Check if already observing this target for (self._observing.items) |*obs| { if (obs.target == target) { obs.options = store_options; return; } } // Register with page if this is our first observation if (self._observing.items.len == 0) { try page.registerMutationObserver(self); } try self._observing.append(arena, .{ .target = target, .options = store_options, }); } pub fn disconnect(self: *MutationObserver, page: *Page) void { for (self._pending_records.items) |record| { record.deinit(false, page._session); } self._pending_records.clearRetainingCapacity(); self._observing.clearRetainingCapacity(); page.unregisterMutationObserver(self); } pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord { const records = try page.call_arena.dupe(*MutationRecord, self._pending_records.items); self._pending_records.clearRetainingCapacity(); return records; } // Called when an attribute changes on any element pub fn notifyAttributeChange( self: *MutationObserver, target: *Element, attribute_name: String, old_value: ?String, page: *Page, ) !void { const target_node = target.asNode(); for (self._observing.items) |obs| { if (obs.target != target_node) { if (!obs.options.subtree) { continue; } if (!obs.target.contains(target_node)) { continue; } } if (!obs.options.attributes) { continue; } if (obs.options.attributeFilter) |filter| { for (filter) |name| { if (attribute_name.eqlSlice(name)) { break; } } else { continue; } } const arena = try page.getArena(.{ .debug = "MutationRecord" }); const record = try arena.create(MutationRecord); record.* = .{ ._arena = arena, ._type = .attributes, ._target = target_node, ._attribute_name = try arena.dupe(u8, attribute_name.str()), ._old_value = if (obs.options.attributeOldValue and old_value != null) try arena.dupe(u8, old_value.?.str()) else null, ._added_nodes = &.{}, ._removed_nodes = &.{}, ._previous_sibling = null, ._next_sibling = null, }; try self._pending_records.append(self._arena, record); try page.scheduleMutationDelivery(); break; } } // Called when character data changes on a text node pub fn notifyCharacterDataChange( self: *MutationObserver, target: *Node, old_value: ?String, page: *Page, ) !void { for (self._observing.items) |obs| { if (obs.target != target) { if (!obs.options.subtree) { continue; } if (!obs.target.contains(target)) { continue; } } if (!obs.options.characterData) { continue; } const arena = try page.getArena(.{ .debug = "MutationRecord" }); const record = try arena.create(MutationRecord); record.* = .{ ._arena = arena, ._type = .characterData, ._target = target, ._attribute_name = null, ._old_value = if (obs.options.characterDataOldValue and old_value != null) try arena.dupe(u8, old_value.?.str()) else null, ._added_nodes = &.{}, ._removed_nodes = &.{}, ._previous_sibling = null, ._next_sibling = null, }; try self._pending_records.append(self._arena, record); try page.scheduleMutationDelivery(); break; } } // Called when children are added or removed from a node pub fn notifyChildListChange( self: *MutationObserver, target: *Node, added_nodes: []const *Node, removed_nodes: []const *Node, previous_sibling: ?*Node, next_sibling: ?*Node, page: *Page, ) !void { for (self._observing.items) |obs| { if (obs.target != target) { if (!obs.options.subtree) { continue; } if (!obs.target.contains(target)) { continue; } } if (!obs.options.childList) { continue; } const arena = try page.getArena(.{ .debug = "MutationRecord" }); const record = try arena.create(MutationRecord); record.* = .{ ._arena = arena, ._type = .childList, ._target = target, ._attribute_name = null, ._old_value = null, ._added_nodes = try arena.dupe(*Node, added_nodes), ._removed_nodes = try arena.dupe(*Node, removed_nodes), ._previous_sibling = previous_sibling, ._next_sibling = next_sibling, }; try self._pending_records.append(self._arena, record); try page.scheduleMutationDelivery(); break; } } pub fn deliverRecords(self: *MutationObserver, page: *Page) !void { if (self._pending_records.items.len == 0) { return; } // Take a copy of the records and clear the list before calling callback // This ensures mutations triggered during the callback go into a fresh list const records = try self.takeRecords(page); var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var caught: js.TryCatch.Caught = undefined; ls.toLocal(self._callback).tryCall(void, .{ records, self }, &caught) catch |err| { log.err(.page, "MutObserver.deliverRecords", .{ .err = err, .caught = caught }); return err; }; } pub const MutationRecord = struct { _type: Type, _target: *Node, _arena: Allocator, _attribute_name: ?[]const u8, _old_value: ?[]const u8, _added_nodes: []const *Node, _removed_nodes: []const *Node, _previous_sibling: ?*Node, _next_sibling: ?*Node, pub const Type = enum { attributes, childList, characterData, }; pub fn deinit(self: *MutationRecord, _: bool, session: *Session) void { session.releaseArena(self._arena); } pub fn getType(self: *const MutationRecord) []const u8 { return switch (self._type) { .attributes => "attributes", .childList => "childList", .characterData => "characterData", }; } pub fn getTarget(self: *const MutationRecord) *Node { return self._target; } pub fn getAttributeNamespace(self: *const MutationRecord) ?[]const u8 { _ = self; // Non-namespaced attribute mutations return null. Full namespace tracking // for setAttributeNS mutations is not yet implemented. return null; } pub fn getAttributeName(self: *const MutationRecord) ?[]const u8 { return self._attribute_name; } pub fn getOldValue(self: *const MutationRecord) ?[]const u8 { return self._old_value; } pub fn getAddedNodes(self: *const MutationRecord) []const *Node { return self._added_nodes; } pub fn getRemovedNodes(self: *const MutationRecord) []const *Node { return self._removed_nodes; } pub fn getPreviousSibling(self: *const MutationRecord) ?*Node { return self._previous_sibling; } pub fn getNextSibling(self: *const MutationRecord) ?*Node { return self._next_sibling; } pub const JsApi = struct { pub const bridge = js.Bridge(MutationRecord); pub const Meta = struct { pub const name = "MutationRecord"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(MutationRecord.deinit); }; pub const @"type" = bridge.accessor(MutationRecord.getType, null, .{}); pub const target = bridge.accessor(MutationRecord.getTarget, null, .{}); pub const attributeName = bridge.accessor(MutationRecord.getAttributeName, null, .{}); pub const attributeNamespace = bridge.accessor(MutationRecord.getAttributeNamespace, null, .{}); pub const oldValue = bridge.accessor(MutationRecord.getOldValue, null, .{}); pub const addedNodes = bridge.accessor(MutationRecord.getAddedNodes, null, .{}); pub const removedNodes = bridge.accessor(MutationRecord.getRemovedNodes, null, .{}); pub const previousSibling = bridge.accessor(MutationRecord.getPreviousSibling, null, .{}); pub const nextSibling = bridge.accessor(MutationRecord.getNextSibling, null, .{}); }; }; pub const JsApi = struct { pub const bridge = js.Bridge(MutationObserver); pub const Meta = struct { pub const name = "MutationObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const finalizer = bridge.finalizer(MutationObserver.deinit); }; pub const constructor = bridge.constructor(MutationObserver.init, .{}); pub const observe = bridge.function(MutationObserver.observe, .{}); pub const disconnect = bridge.function(MutationObserver.disconnect, .{}); pub const takeRecords = bridge.function(MutationObserver.takeRecords, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: MutationObserver" { try testing.htmlRunner("mutation_observer", .{}); } ================================================ FILE: src/browser/webapi/Navigator.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); const log = @import("../../log.zig"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const PluginArray = @import("PluginArray.zig"); const Permissions = @import("Permissions.zig"); const StorageManager = @import("StorageManager.zig"); const Navigator = @This(); _pad: bool = false, _plugins: PluginArray = .{}, _permissions: Permissions = .{}, _storage: StorageManager = .{}, pub const init: Navigator = .{}; pub fn getUserAgent(_: *const Navigator, page: *Page) []const u8 { return page._session.browser.app.config.http_headers.user_agent; } pub fn getLanguages(_: *const Navigator) [1][]const u8 { return .{"en-US"}; } pub fn getPlatform(_: *const Navigator) []const u8 { return switch (builtin.os.tag) { .macos => "MacIntel", .windows => "Win32", .linux => "Linux x86_64", .freebsd => "FreeBSD", else => "Unknown", }; } /// Returns whether Java is enabled (always false) pub fn javaEnabled(_: *const Navigator) bool { return false; } pub fn getPlugins(self: *Navigator) *PluginArray { return &self._plugins; } pub fn getPermissions(self: *Navigator) *Permissions { return &self._permissions; } pub fn getStorage(self: *Navigator) *StorageManager { return &self._storage; } pub fn getBattery(_: *const Navigator, page: *Page) !js.Promise { log.info(.not_implemented, "navigator.getBattery", .{}); return page.js.local.?.rejectErrorPromise(.{ .dom_exception = error.NotSupported }); } pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void { try validateProtocolHandlerScheme(scheme); try validateProtocolHandlerURL(url, page); } pub fn unregisterProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void { try validateProtocolHandlerScheme(scheme); try validateProtocolHandlerURL(url, page); } fn validateProtocolHandlerScheme(scheme: []const u8) !void { const allowed = std.StaticStringMap(void).initComptime(.{ .{ "bitcoin", {} }, .{ "cabal", {} }, .{ "dat", {} }, .{ "did", {} }, .{ "dweb", {} }, .{ "ethereum", .{} }, .{ "ftp", {} }, .{ "ftps", {} }, .{ "geo", {} }, .{ "im", {} }, .{ "ipfs", {} }, .{ "ipns", .{} }, .{ "irc", {} }, .{ "ircs", {} }, .{ "hyper", {} }, .{ "magnet", {} }, .{ "mailto", {} }, .{ "matrix", {} }, .{ "mms", {} }, .{ "news", {} }, .{ "nntp", {} }, .{ "openpgp4fpr", {} }, .{ "sftp", {} }, .{ "sip", {} }, .{ "sms", {} }, .{ "smsto", {} }, .{ "ssb", {} }, .{ "ssh", {} }, .{ "tel", {} }, .{ "urn", {} }, .{ "webcal", {} }, .{ "wtai", {} }, .{ "xmpp", {} }, }); if (allowed.has(scheme)) { return; } if (scheme.len < 5 or !std.mem.startsWith(u8, scheme, "web+")) { return error.SecurityError; } for (scheme[4..]) |b| { if (std.ascii.isLower(b) == false) { return error.SecurityError; } } } fn validateProtocolHandlerURL(url: [:0]const u8, page: *const Page) !void { if (std.mem.indexOf(u8, url, "%s") == null) { return error.SyntaxError; } if (try page.isSameOrigin(url) == false) { return error.SyntaxError; } } pub const JsApi = struct { pub const bridge = js.Bridge(Navigator); pub const Meta = struct { pub const name = "Navigator"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; // Read-only properties pub const userAgent = bridge.accessor(Navigator.getUserAgent, null, .{}); pub const appName = bridge.property("Netscape", .{ .template = false }); pub const appCodeName = bridge.property("Netscape", .{ .template = false }); pub const appVersion = bridge.property("1.0", .{ .template = false }); pub const platform = bridge.accessor(Navigator.getPlatform, null, .{}); pub const language = bridge.property("en-US", .{ .template = false }); pub const languages = bridge.accessor(Navigator.getLanguages, null, .{}); pub const onLine = bridge.property(true, .{ .template = false }); pub const cookieEnabled = bridge.property(true, .{ .template = false }); pub const hardwareConcurrency = bridge.property(4, .{ .template = false }); pub const deviceMemory = bridge.property(@as(f64, 8.0), .{ .template = false }); pub const maxTouchPoints = bridge.property(0, .{ .template = false }); pub const vendor = bridge.property("", .{ .template = false }); pub const product = bridge.property("Gecko", .{ .template = false }); pub const webdriver = bridge.property(false, .{ .template = false }); pub const plugins = bridge.accessor(Navigator.getPlugins, null, .{}); pub const doNotTrack = bridge.property(null, .{ .template = false }); pub const globalPrivacyControl = bridge.property(true, .{ .template = false }); pub const registerProtocolHandler = bridge.function(Navigator.registerProtocolHandler, .{ .dom_exception = true }); pub const unregisterProtocolHandler = bridge.function(Navigator.unregisterProtocolHandler, .{ .dom_exception = true }); // Methods pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{}); pub const getBattery = bridge.function(Navigator.getBattery, .{}); pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{}); pub const storage = bridge.accessor(Navigator.getStorage, null, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: Navigator" { try testing.htmlRunner("navigator", .{}); } ================================================ FILE: src/browser/webapi/Node.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../log.zig"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const reflect = @import("../reflect.zig"); const EventTarget = @import("EventTarget.zig"); const collections = @import("collections.zig"); pub const CData = @import("CData.zig"); pub const Element = @import("Element.zig"); pub const Document = @import("Document.zig"); pub const HTMLDocument = @import("HTMLDocument.zig"); pub const Children = @import("children.zig").Children; pub const DocumentFragment = @import("DocumentFragment.zig"); pub const DocumentType = @import("DocumentType.zig"); pub const ShadowRoot = @import("ShadowRoot.zig"); const Allocator = std.mem.Allocator; const LinkedList = std.DoublyLinkedList; const Node = @This(); _type: Type, _proto: *EventTarget, _parent: ?*Node = null, _children: ?*Children = null, _child_link: LinkedList.Node = .{}, // Lookup for nodes that have a different owner document than page.document pub const OwnerDocumentLookup = std.AutoHashMapUnmanaged(*Node, *Document); pub const Type = union(enum) { cdata: *CData, element: *Element, document: *Document, document_type: *DocumentType, attribute: *Element.Attribute, document_fragment: *DocumentFragment, }; pub fn asEventTarget(self: *Node) *EventTarget { return self._proto; } // Returns the node as a more specific type. Will crash if node is not a `T`. // Use `is` to optionally get the node as T pub fn as(self: *Node, comptime T: type) *T { return self.is(T).?; } // Return the node as a more specific type or `null` if the node is not a `T`. pub fn is(self: *Node, comptime T: type) ?*T { const type_name = @typeName(T); switch (self._type) { .element => |el| { if (T == Element) { return el; } if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.")) { return el.is(T); } }, .cdata => |cd| { if (T == CData) { return cd; } if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.cdata.")) { return cd.is(T); } }, .attribute => |attr| { if (T == Element.Attribute) { return attr; } }, .document => |doc| { if (T == Document) { return doc; } if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.htmldocument.")) { return doc.is(T); } }, .document_type => |dt| { if (T == DocumentType) { return dt; } }, .document_fragment => |doc| { if (T == DocumentFragment) { return doc; } if (T == ShadowRoot) { return doc.is(ShadowRoot); } }, } return null; } /// Given a position, returns target and previous nodes required for /// insertAdjacentHTML, insertAdjacentElement and insertAdjacentText. /// * `target_node` is `*Node` (where we actually insert), /// * `previous_node` is `?*Node`. pub fn findAdjacentNodes(self: *Node, position: []const u8) !struct { *Node, ?*Node } { // Case-insensitive match per HTML spec. // "beforeend" was the most common case in my tests; we might adjust the order // depending on which ones websites prefer most. if (std.ascii.eqlIgnoreCase(position, "beforeend")) { return .{ self, null }; } if (std.ascii.eqlIgnoreCase(position, "afterbegin")) { // Get the first child; null indicates there are no children. return .{ self, self.firstChild() }; } if (std.ascii.eqlIgnoreCase(position, "beforebegin")) { // The node must have a parent node in order to use this variant. const parent_node = self.parentNode() orelse return error.NoModificationAllowed; // Parent cannot be Document. switch (parent_node._type) { .document, .document_fragment => return error.NoModificationAllowed, else => {}, } return .{ parent_node, self }; } if (std.ascii.eqlIgnoreCase(position, "afterend")) { // The node must have a parent node in order to use this variant. const parent_node = self.parentNode() orelse return error.NoModificationAllowed; // Parent cannot be Document. switch (parent_node._type) { .document, .document_fragment => return error.NoModificationAllowed, else => {}, } // Get the next sibling or null; null indicates our node is the only one. return .{ parent_node, self.nextSibling() }; } // Returned if: // * position is not one of the four listed values. // * The input is XML that is not well-formed. return error.Syntax; } pub fn firstChild(self: *const Node) ?*Node { const children = self._children orelse return null; return children.first(); } pub fn lastChild(self: *const Node) ?*Node { const children = self._children orelse return null; return children.last(); } pub fn nextSibling(self: *const Node) ?*Node { return linkToNodeOrNull(self._child_link.next); } pub fn previousSibling(self: *const Node) ?*Node { return linkToNodeOrNull(self._child_link.prev); } pub fn parentNode(self: *const Node) ?*Node { return self._parent; } pub fn parentElement(self: *const Node) ?*Element { const parent = self._parent orelse return null; return parent.is(Element); } // Validates that a node can be inserted as a child of parent. fn validateNodeInsertion(parent: *Node, node: *Node) !void { // Check if parent is a valid type to have children if (parent._type != .document and parent._type != .element and parent._type != .document_fragment) { return error.HierarchyError; } // Check if node contains parent (would create a cycle) if (node.contains(parent)) { return error.HierarchyError; } if (node._type == .attribute) { return error.HierarchyError; } // Doctype nodes can only be inserted into a Document if (node._type == .document_type and parent._type != .document) { return error.HierarchyError; } } pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node { if (child.is(DocumentFragment)) |_| { try page.appendAllChildren(child, self); return child; } try validateNodeInsertion(self, child); page.domChanged(); // If the child is currently connected, and if its new parent is connected, // then we can remove + add a bit more efficiently (we don't have to fully // disconnect then reconnect) const child_connected = child.isConnected(); // Check if we're adopting the node to a different document const child_owner = child.ownerDocument(page); const parent_owner = self.ownerDocument(page) orelse self.as(Document); const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner; if (child._parent) |parent| { // we can signal removeNode that the child will remain connected // (when it's appended to self) so that it can be a bit more efficient. page.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { try page.adoptNodeTree(child, parent_owner); } try page.appendNode(self, child, .{ .child_already_connected = child_connected, .adopting_to_new_document = adopting_to_new_document, }); return child; } pub fn childNodes(self: *Node, page: *Page) !*collections.ChildNodes { return collections.ChildNodes.init(self, page); } pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void { switch (self._type) { .element, .document_fragment => { var it = self.childrenIterator(); while (it.next()) |child| { // ignore comments and processing instructions. if (child.is(CData.Comment) != null or child.is(CData.ProcessingInstruction) != null) { continue; } try child.getTextContent(writer); } }, .cdata => |c| try writer.writeAll(c._data.str()), .document => {}, .document_type => {}, .attribute => |attr| try writer.writeAll(attr._value.str()), } } pub fn getTextContentAlloc(self: *Node, allocator: Allocator) error{WriteFailed}![:0]const u8 { var buf = std.Io.Writer.Allocating.init(allocator); try self.getTextContent(&buf.writer); try buf.writer.writeByte(0); const data = buf.written(); return data[0 .. data.len - 1 :0]; } /// Returns the "child text content" which is the concatenation of the data /// of all the Text node children of the node, in tree order. /// This differs from textContent which includes all descendant text. /// See: https://dom.spec.whatwg.org/#concept-child-text-content pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void { var it = self.childrenIterator(); while (it.next()) |child| { if (child.is(CData.Text)) |text| { try writer.writeAll(text._proto._data.str()); } } } pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { switch (self._type) { .element => |el| { if (data.len == 0) { return el.replaceChildren(&.{}, page); } return el.replaceChildren(&.{.{ .text = data }}, page); }, // Per spec, setting textContent on CharacterData runs replaceData(0, length, value) .cdata => |c| try c.replaceData(0, c.getLength(), data, page), .document => {}, .document_type => {}, .document_fragment => |frag| { if (data.len == 0) { return frag.replaceChildren(&.{}, page); } return frag.replaceChildren(&.{.{ .text = data }}, page); }, .attribute => |attr| return attr.setValue(.wrap(data), page), } } pub fn getNodeName(self: *const Node, buf: []u8) []const u8 { return switch (self._type) { .element => |el| el.getTagNameSpec(buf), .cdata => |cd| switch (cd._type) { .text => "#text", .cdata_section => "#cdata-section", .comment => "#comment", .processing_instruction => |pi| pi._target, }, .document => "#document", .document_type => |dt| dt.getName(), .document_fragment => "#document-fragment", .attribute => |attr| attr._name.str(), }; } pub fn getNodeType(self: *const Node) u8 { return switch (self._type) { .element => 1, .attribute => 2, .cdata => |cd| switch (cd._type) { .text => 3, .cdata_section => 4, .processing_instruction => 7, .comment => 8, }, .document => 9, .document_type => 10, .document_fragment => 11, }; } pub fn lookupNamespaceURI(self: *Node, prefix_arg: ?[]const u8, page: *Page) ?[]const u8 { const prefix: ?[]const u8 = if (prefix_arg) |p| (if (p.len == 0) null else p) else null; switch (self._type) { .element => |el| return el.lookupNamespaceURIForElement(prefix, page), .document => |doc| { const de = doc.getDocumentElement() orelse return null; return de.lookupNamespaceURIForElement(prefix, page); }, .document_type, .document_fragment => return null, .attribute => |attr| { const owner = attr.getOwnerElement() orelse return null; return owner.lookupNamespaceURIForElement(prefix, page); }, .cdata => { const parent = self.parentElement() orelse return null; return parent.lookupNamespaceURIForElement(prefix, page); }, } } pub fn isDefaultNamespace(self: *Node, namespace_arg: ?[]const u8, page: *Page) bool { const namespace: ?[]const u8 = if (namespace_arg) |ns| (if (ns.len == 0) null else ns) else null; const default_ns = self.lookupNamespaceURI(null, page); if (default_ns == null and namespace == null) return true; if (default_ns != null and namespace != null) return std.mem.eql(u8, default_ns.?, namespace.?); return false; } pub fn isEqualNode(self: *Node, other: *Node) bool { if (self == other) { return true; } // Make sure types match. if (self.getNodeType() != other.getNodeType()) { return false; } // TODO: Compare `localName` and prefix. return switch (self._type) { .element => self.as(Element).isEqualNode(other.as(Element)), .attribute => self.as(Element.Attribute).isEqualNode(other.as(Element.Attribute)), .cdata => self.as(CData).isEqualNode(other.as(CData)), .document_fragment => self.as(DocumentFragment).isEqualNode(other.as(DocumentFragment)), .document_type => self.as(DocumentType).isEqualNode(other.as(DocumentType)), .document => { // Document comparison is complex and rarely used in practice log.warn(.not_implemented, "Node.isEqualNode", .{ .type = "document", }); return false; }, }; } pub fn isInShadowTree(self: *Node) bool { var node = self._parent; while (node) |n| { if (n.is(ShadowRoot) != null) { return true; } node = n._parent; } return false; } pub fn isConnected(self: *const Node) bool { // Walk up to find the root node var root = self; while (root._parent) |parent| { root = parent; } switch (root._type) { .document => return true, .document_fragment => |df| { const sr = df.is(ShadowRoot) orelse return false; return sr._host.asNode().isConnected(); }, else => return false, } } const GetRootNodeOpts = struct { composed: bool = false, }; pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node { const opts = opts_ orelse GetRootNodeOpts{}; var root = self; while (root._parent) |parent| { root = parent; } // If composed is true, traverse through shadow boundaries if (opts.composed) { while (true) { const shadow_root = @constCast(root).is(ShadowRoot) orelse break; root = shadow_root.getHost().asNode(); while (root._parent) |parent| { root = parent; } } } return root; } pub fn contains(self: *const Node, child_: ?*const Node) bool { const child = child_ orelse return false; if (self == child) { // yes, this is correct return true; } var parent = child._parent; while (parent) |p| { if (p == self) { return true; } parent = p._parent; } return false; } pub fn ownerDocument(self: *const Node, page: *const Page) ?*Document { // A document node does not have an owner. if (self._type == .document) { return null; } // The root of the tree that a node belongs to is its owner. var current = self; while (current._parent) |parent| { current = parent; } // If the root is a document, then that's our owner. if (current._type == .document) { return current._type.document; } // Otherwise, this is a detached node. Check if it has a specific owner // document registered (for nodes created via non-main documents). if (page._node_owner_documents.get(@constCast(self))) |owner| { return owner; } // Default to the main document for detached nodes without a specific owner. return page.document; } pub fn ownerPage(self: *const Node, default: *Page) *Page { const doc = self.ownerDocument(default) orelse return default; return doc._page orelse default; } pub fn isSameDocumentAs(self: *const Node, other: *const Node, page: *const Page) bool { // Get the root document for each node const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(page); const other_doc = if (other._type == .document) other._type.document else other.ownerDocument(page); return self_doc == other_doc; } pub fn hasChildNodes(self: *const Node) bool { return self.firstChild() != null; } pub fn isSameNode(self: *const Node, other: ?*Node) bool { return self == other; } pub fn removeChild(self: *Node, child: *Node, page: *Page) !*Node { var it = self.childrenIterator(); while (it.next()) |n| { if (n == child) { page.domChanged(); page.removeNode(self, child, .{ .will_be_reconnected = false }); return child; } } return error.NotFound; } pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page) !*Node { const ref_node = ref_node_ orelse { return self.appendChild(new_node, page); }; // special case: if nodes are the same, ignore the change. if (new_node == ref_node_) { page.domChanged(); if (page.hasMutationObservers()) { const parent = new_node._parent.?; const previous_sibling = new_node.previousSibling(); const next_sibling = new_node.nextSibling(); const replaced = [_]*Node{new_node}; page.childListChange(parent, &replaced, &replaced, previous_sibling, next_sibling); } return new_node; } if (ref_node._parent == null or ref_node._parent.? != self) { return error.NotFound; } if (new_node.is(DocumentFragment)) |_| { try page.insertAllChildrenBefore(new_node, self, ref_node); return new_node; } try validateNodeInsertion(self, new_node); const child_already_connected = new_node.isConnected(); // Check if we're adopting the node to a different document const child_owner = new_node.ownerDocument(page); const parent_owner = self.ownerDocument(page) orelse self.as(Document); const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner; page.domChanged(); const will_be_reconnected = self.isConnected(); if (new_node._parent) |parent| { page.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { try page.adoptNodeTree(new_node, parent_owner); } try page.insertNodeRelative( self, new_node, .{ .before = ref_node }, .{ .child_already_connected = child_already_connected, .adopting_to_new_document = adopting_to_new_document, }, ); return new_node; } pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, page: *Page) !*Node { if (old_child._parent == null or old_child._parent.? != self) { return error.HierarchyError; } try validateNodeInsertion(self, new_child); _ = try self.insertBefore(new_child, old_child, page); // Special case: if we replace a node by itself, we don't remove it. // insertBefore is an noop in this case. if (new_child != old_child) { page.removeNode(self, old_child, .{ .will_be_reconnected = false }); } return old_child; } pub fn getNodeValue(self: *const Node) ?String { return switch (self._type) { .cdata => |c| c.getData(), .attribute => |attr| attr._value, .element => null, .document => null, .document_type => null, .document_fragment => null, }; } pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void { switch (self._type) { // Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value) .cdata => |c| { const new_value: []const u8 = if (value) |v| v.str() else ""; try c.replaceData(0, c.getLength(), new_value, page); }, .attribute => |attr| try attr.setValue(value, page), .element => {}, .document => {}, .document_type => {}, .document_fragment => {}, } } pub fn format(self: *Node, writer: *std.Io.Writer) !void { // // If you need extra debugging: // return @import("../dump.zig").deep(self, .{}, writer); return switch (self._type) { .cdata => |cd| cd.format(writer), .element => |el| writer.print("{f}", .{el}), .document => writer.writeAll("<document>"), .document_type => writer.writeAll("<doctype>"), .document_fragment => writer.writeAll("<document_fragment>"), .attribute => |attr| writer.print("{f}", .{attr}), }; } // Returns an iterator the can be used to iterate through the node's children // For internal use. pub fn childrenIterator(self: *Node) NodeIterator { const children = self._children orelse { return .{ .node = null }; }; return .{ .node = children.first(), }; } pub fn getChildrenCount(self: *Node) usize { return switch (self._type) { .element, .document, .document_fragment => self.getLength(), .document_type, .attribute, .cdata => return 0, }; } pub fn getLength(self: *Node) u32 { switch (self._type) { .cdata => |cdata| { return @intCast(cdata.getData().len); }, .element, .document, .document_fragment => { var count: u32 = 0; var it = self.childrenIterator(); while (it.next()) |_| { count += 1; } return count; }, .document_type, .attribute => return 0, } } pub fn getChildIndex(self: *Node, target: *const Node) ?u32 { var i: u32 = 0; var it = self.childrenIterator(); while (it.next()) |child| { if (child == target) { return i; } i += 1; } return null; } pub fn getChildAt(self: *Node, index: u32) ?*Node { var i: u32 = 0; var it = self.childrenIterator(); while (it.next()) |child| { if (i == index) { return child; } i += 1; } return null; } pub fn getData(self: *const Node) String { return switch (self._type) { .cdata => |c| c.getData(), else => .empty, }; } pub fn setData(self: *Node, data: []const u8, page: *Page) !void { switch (self._type) { .cdata => |c| try c.setData(data, page), else => {}, } } pub fn normalize(self: *Node, page: *Page) !void { var buffer: std.ArrayList(u8) = .empty; return self._normalize(page.call_arena, &buffer, page); } const CloneError = error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError, IFrameLoadError, TooManyContexts, LinkLoadError, StyleLoadError, TypeError, CompilationError, JsException, }; pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node { const deep = deep_ orelse false; switch (self._type) { .cdata => |cd| { const data = cd.getData().str(); return switch (cd._type) { .text => page.createTextNode(data), .cdata_section => page.createCDATASection(data), .comment => page.createComment(data), .processing_instruction => |pi| page.createProcessingInstruction(pi._target, data), }; }, .element => |el| return el.clone(deep, page), .document => return error.NotSupported, .document_type => |dt| { const cloned = dt.clone(page) catch return error.CloneError; return cloned.asNode(); }, .document_fragment => |frag| return frag.cloneFragment(deep, page), .attribute => |attr| { const cloned = attr.clone(page) catch return error.CloneError; return cloned._proto; }, } } /// Clone a node for the purpose of appending to a parent. /// Returns null if the cloned node was already attached somewhere by a custom element /// constructor, indicating that the constructor's decision should be respected. /// /// This helper is used when iterating over children to clone them. The typical pattern is: /// while (child_it.next()) |child| { /// if (try child.cloneNodeForAppending(true, page)) |cloned| { /// try page.appendNode(parent, cloned, opts); /// } /// } /// /// The only case where a cloned node would already have a parent is when a custom element /// constructor (which runs during cloning per the HTML spec) explicitly attaches the element /// somewhere. In that case, we respect the constructor's decision and return null to signal /// that the cloned node should not be appended to our intended parent. pub fn cloneNodeForAppending(self: *Node, deep: bool, page: *Page) CloneError!?*Node { const cloned = try self.cloneNode(deep, page); if (cloned._parent != null) { return null; } return cloned; } pub fn compareDocumentPosition(self: *Node, other: *Node) u16 { const DISCONNECTED: u16 = 0x01; const PRECEDING: u16 = 0x02; const FOLLOWING: u16 = 0x04; const CONTAINS: u16 = 0x08; const CONTAINED_BY: u16 = 0x10; const IMPLEMENTATION_SPECIFIC: u16 = 0x20; if (self == other) { return 0; } // Check if either node is disconnected const self_root = self.getRootNode(.{}); const other_root = other.getRootNode(.{}); if (self_root != other_root) { // Nodes are in different trees - disconnected // Use pointer comparison for implementation-specific ordering return DISCONNECTED | IMPLEMENTATION_SPECIFIC | if (@intFromPtr(self) < @intFromPtr(other)) FOLLOWING else PRECEDING; } // Check if one contains the other if (self.contains(other)) { return FOLLOWING | CONTAINED_BY; } if (other.contains(self)) { return PRECEDING | CONTAINS; } // Neither contains the other - find common ancestor and compare positions // Walk up from self to build ancestor chain var self_ancestors: [256]*const Node = undefined; var ancestor_count: usize = 0; var current: ?*const Node = self; while (current) |node| : (current = node._parent) { if (ancestor_count >= self_ancestors.len) break; self_ancestors[ancestor_count] = node; ancestor_count += 1; } const ancestors = self_ancestors[0..ancestor_count]; // Walk up from other until we find common ancestor current = other; while (current) |node| : (current = node._parent) { // Check if this node is in self's ancestor chain for (ancestors, 0..) |ancestor, i| { if (ancestor != node) { continue; } // Found common ancestor // Compare the children that are ancestors of self and other if (i == 0) { // self is directly under the common ancestor // Find other's ancestor that's a child of the common ancestor if (other == node) { // other is the common ancestor, so self follows it return FOLLOWING; } var other_ancestor = other; while (other_ancestor._parent) |p| { if (p == node) break; other_ancestor = p; } return if (isNodeBefore(self, other_ancestor)) FOLLOWING else PRECEDING; } const self_ancestor = self_ancestors[i - 1]; // Find other's ancestor that's a child of the common ancestor var other_ancestor = other; if (other == node) { // other is the common ancestor, so self is contained by it return PRECEDING | CONTAINS; } while (other_ancestor._parent) |p| { if (p == node) break; other_ancestor = p; } return if (isNodeBefore(self_ancestor, other_ancestor)) FOLLOWING else PRECEDING; } } // Shouldn't reach here if both nodes are in the same tree return DISCONNECTED; } // faster to compare the linked list node links directly fn isNodeBefore(node1: *const Node, node2: *const Node) bool { var current = node1._child_link.next; const target = &node2._child_link; while (current) |link| { if (link == target) return true; current = link.next; } return false; } fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), page: *Page) !void { var it = self.childrenIterator(); while (it.next()) |child| { try child._normalize(allocator, buffer, page); } var child = self.firstChild(); while (child) |current_node| { var next_node = current_node.nextSibling(); const text_node = current_node.is(CData.Text) orelse { child = next_node; continue; }; if (text_node._proto.getData().len == 0) { page.removeNode(self, current_node, .{ .will_be_reconnected = false }); child = next_node; continue; } if (next_node) |next| { if (next.is(CData.Text)) |_| { try buffer.appendSlice(allocator, text_node.getWholeText()); while (next_node) |node_to_merge| { const next_text_node = node_to_merge.is(CData.Text) orelse break; try buffer.appendSlice(allocator, next_text_node.getWholeText()); const to_remove = node_to_merge; next_node = node_to_merge.nextSibling(); page.removeNode(self, to_remove, .{ .will_be_reconnected = false }); } text_node._proto._data = try page.dupeSSO(buffer.items); buffer.clearRetainingCapacity(); } } child = next_node; } } pub const GetElementsByTagNameResult = union(enum) { tag: collections.NodeLive(.tag), tag_name: collections.NodeLive(.tag_name), all_elements: collections.NodeLive(.all_elements), }; // Not exposed in the WebAPI, but used by both Element and Document pub fn getElementsByTagName(self: *Node, tag_name: []const u8, page: *Page) !GetElementsByTagNameResult { if (tag_name.len > 256) { // 256 seems generous. return error.InvalidTagName; } if (std.mem.eql(u8, tag_name, "*")) { return .{ .all_elements = collections.NodeLive(.all_elements).init(self, {}, page), }; } const lower = std.ascii.lowerString(&page.buf, tag_name); if (Node.Element.Tag.parseForMatch(lower)) |known| { // optimized for known tag names, comparis return .{ .tag = collections.NodeLive(.tag).init(self, known, page), }; } const arena = page.arena; const filter = try String.init(arena, lower, .{}); return .{ .tag_name = collections.NodeLive(.tag_name).init(self, filter, page) }; } // Not exposed in the WebAPI, but used by both Element and Document pub fn getElementsByTagNameNS(self: *Node, namespace: ?[]const u8, local_name: []const u8, page: *Page) !collections.NodeLive(.tag_name_ns) { if (local_name.len > 256) { return error.InvalidTagName; } // Parse namespace - "*" means wildcard (null), null means Element.Namespace.null const ns: ?Element.Namespace = if (namespace) |ns_str| if (std.mem.eql(u8, ns_str, "*")) null else Element.Namespace.parse(ns_str) else Element.Namespace.null; return collections.NodeLive(.tag_name_ns).init(self, .{ .namespace = ns, .local_name = try String.init(page.arena, local_name, .{}), }, page); } // Not exposed in the WebAPI, but used by both Element and Document pub fn getElementsByClassName(self: *Node, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) { const arena = page.arena; // Parse space-separated class names var class_names: std.ArrayList([]const u8) = .empty; var it = std.mem.tokenizeAny(u8, class_name, "\t\n\x0C\r "); while (it.next()) |name| { try class_names.append(arena, try page.dupeString(name)); } return collections.NodeLive(.class_name).init(self, class_names.items, page); } // Writes a JSON representation of the node and its children pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void { // stupid json api requires this to be const, // so we @constCast it because our stringify re-uses code that can be // used to iterate nodes, e.g. the NodeIterator return @import("../dump.zig").toJSON(@constCast(self), writer); } const NodeIterator = struct { node: ?*Node, pub fn next(self: *NodeIterator) ?*Node { const node = self.node orelse return null; self.node = linkToNodeOrNull(node._child_link.next); return node; } }; // Turns a linked list node into a Node pub fn linkToNode(n: *LinkedList.Node) *Node { return @fieldParentPtr("_child_link", n); } pub fn linkToNodeOrNull(n_: ?*LinkedList.Node) ?*Node { return if (n_) |n| linkToNode(n) else null; } pub const JsApi = struct { pub const bridge = js.Bridge(Node); pub const Meta = struct { pub const name = "Node"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const ELEMENT_NODE = bridge.property(1, .{ .template = true }); pub const ATTRIBUTE_NODE = bridge.property(2, .{ .template = true }); pub const TEXT_NODE = bridge.property(3, .{ .template = true }); pub const CDATA_SECTION_NODE = bridge.property(4, .{ .template = true }); pub const ENTITY_REFERENCE_NODE = bridge.property(5, .{ .template = true }); pub const ENTITY_NODE = bridge.property(6, .{ .template = true }); pub const PROCESSING_INSTRUCTION_NODE = bridge.property(7, .{ .template = true }); pub const COMMENT_NODE = bridge.property(8, .{ .template = true }); pub const DOCUMENT_NODE = bridge.property(9, .{ .template = true }); pub const DOCUMENT_TYPE_NODE = bridge.property(10, .{ .template = true }); pub const DOCUMENT_FRAGMENT_NODE = bridge.property(11, .{ .template = true }); pub const NOTATION_NODE = bridge.property(12, .{ .template = true }); pub const DOCUMENT_POSITION_DISCONNECTED = bridge.property(0x01, .{ .template = true }); pub const DOCUMENT_POSITION_PRECEDING = bridge.property(0x02, .{ .template = true }); pub const DOCUMENT_POSITION_FOLLOWING = bridge.property(0x04, .{ .template = true }); pub const DOCUMENT_POSITION_CONTAINS = bridge.property(0x08, .{ .template = true }); pub const DOCUMENT_POSITION_CONTAINED_BY = bridge.property(0x10, .{ .template = true }); pub const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = bridge.property(0x20, .{ .template = true }); pub const nodeName = bridge.accessor(struct { fn wrap(self: *const Node, page: *Page) []const u8 { return self.getNodeName(&page.buf); } }.wrap, null, .{}); pub const nodeType = bridge.accessor(Node.getNodeType, null, .{}); pub const textContent = bridge.accessor(_textContext, Node.setTextContent, .{}); fn _textContext(self: *Node, page: *const Page) !?[]const u8 { // cdata and attributes can return value directly, avoiding the copy switch (self._type) { .element, .document_fragment => { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getTextContent(&buf.writer); return buf.written(); }, .cdata => |cdata| return cdata._data.str(), .attribute => |attr| return attr._value.str(), .document => return null, .document_type => return null, } } pub const firstChild = bridge.accessor(Node.firstChild, null, .{}); pub const lastChild = bridge.accessor(Node.lastChild, null, .{}); pub const nextSibling = bridge.accessor(Node.nextSibling, null, .{}); pub const previousSibling = bridge.accessor(Node.previousSibling, null, .{}); pub const parentNode = bridge.accessor(Node.parentNode, null, .{}); pub const parentElement = bridge.accessor(Node.parentElement, null, .{}); pub const appendChild = bridge.function(Node.appendChild, .{ .dom_exception = true }); pub const childNodes = bridge.accessor(Node.childNodes, null, .{ .cache = .{ .private = "child_nodes" } }); pub const isConnected = bridge.accessor(Node.isConnected, null, .{}); pub const ownerDocument = bridge.accessor(Node.ownerDocument, null, .{}); pub const hasChildNodes = bridge.function(Node.hasChildNodes, .{}); pub const isSameNode = bridge.function(Node.isSameNode, .{}); pub const contains = bridge.function(Node.contains, .{}); pub const removeChild = bridge.function(Node.removeChild, .{ .dom_exception = true }); pub const nodeValue = bridge.accessor(Node.getNodeValue, Node.setNodeValue, .{}); pub const insertBefore = bridge.function(Node.insertBefore, .{ .dom_exception = true }); pub const replaceChild = bridge.function(Node.replaceChild, .{ .dom_exception = true }); pub const normalize = bridge.function(Node.normalize, .{}); pub const cloneNode = bridge.function(Node.cloneNode, .{ .dom_exception = true }); pub const compareDocumentPosition = bridge.function(Node.compareDocumentPosition, .{}); pub const getRootNode = bridge.function(Node.getRootNode, .{}); pub const isEqualNode = bridge.function(Node.isEqualNode, .{}); pub const lookupNamespaceURI = bridge.function(Node.lookupNamespaceURI, .{}); pub const isDefaultNamespace = bridge.function(Node.isDefaultNamespace, .{}); fn _baseURI(_: *Node, page: *const Page) []const u8 { return page.base(); } pub const baseURI = bridge.accessor(_baseURI, null, .{}); }; pub const Build = struct { // Calls `func_name` with `args` on the most specific type where it is // implement. This could be on the Node itself (as a last-resort); pub fn call(self: *const Node, comptime func_name: []const u8, args: anytype) !void { inline for (@typeInfo(Node.Type).@"union".fields) |f| { // The inner type has its own "call" method. Defer to it. if (@field(Node.Type, f.name) == self._type) { const S = reflect.Struct(f.type); if (@hasDecl(S, "Build")) { if (@hasDecl(S.Build, "call")) { const sub = @field(self._type, f.name); if (try S.Build.call(sub, func_name, args)) { return; } } // The inner type implements this function. Call it and we're done. if (@hasDecl(S, func_name)) { return @call(.auto, @field(f.type, func_name), args); } } } } if (@hasDecl(Node.Build, func_name)) { // Our last resort - the node implements this function. return @call(.auto, @field(Node.Build, func_name), args); } } }; pub const NodeOrText = union(enum) { node: *Node, text: []const u8, pub fn format(self: *const NodeOrText, writer: *std.io.Writer) !void { switch (self.*) { .node => |n| try n.format(writer), .text => |text| { try writer.writeByte('\''); try writer.writeAll(text); try writer.writeByte('\''); }, } } pub fn toNode(self: *const NodeOrText, page: *Page) !*Node { return switch (self.*) { .node => |n| n, .text => |txt| page.createTextNode(txt), }; } /// DOM spec: first following sibling of `node` that is not in `nodes`. pub fn viableNextSibling(node: *Node, nodes: []const NodeOrText) ?*Node { var sibling = node.nextSibling() orelse return null; blk: while (true) { for (nodes) |n| { switch (n) { .node => |nn| if (sibling == nn) { sibling = sibling.nextSibling() orelse return null; continue :blk; }, .text => {}, } } else { return sibling; } } return null; } }; const testing = @import("../../testing.zig"); test "WebApi: Node" { try testing.htmlRunner("node", .{}); } ================================================ FILE: src/browser/webapi/NodeFilter.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const Node = @import("Node.zig"); const NodeFilter = @This(); _func: ?js.Function.Global, _original_filter: ?FilterOpts, pub const FilterOpts = union(enum) { function: js.Function.Global, object: struct { pub const js_as_object = true; acceptNode: js.Function.Global, }, }; pub fn init(opts_: ?FilterOpts) !NodeFilter { const opts = opts_ orelse return .{ ._func = null, ._original_filter = null }; const func = switch (opts) { .function => |func| func, .object => |obj| obj.acceptNode, }; return .{ ._func = func, ._original_filter = opts_, }; } // Constants pub const FILTER_ACCEPT: i32 = 1; pub const FILTER_REJECT: i32 = 2; pub const FILTER_SKIP: i32 = 3; // whatToShow constants pub const SHOW_ALL: u32 = 0xFFFFFFFF; pub const SHOW_ELEMENT: u32 = 0x1; pub const SHOW_ATTRIBUTE: u32 = 0x2; pub const SHOW_TEXT: u32 = 0x4; pub const SHOW_CDATA_SECTION: u32 = 0x8; pub const SHOW_ENTITY_REFERENCE: u32 = 0x10; pub const SHOW_ENTITY: u32 = 0x20; pub const SHOW_PROCESSING_INSTRUCTION: u32 = 0x40; pub const SHOW_COMMENT: u32 = 0x80; pub const SHOW_DOCUMENT: u32 = 0x100; pub const SHOW_DOCUMENT_TYPE: u32 = 0x200; pub const SHOW_DOCUMENT_FRAGMENT: u32 = 0x400; pub const SHOW_NOTATION: u32 = 0x800; pub fn acceptNode(self: *const NodeFilter, node: *Node, local: *const js.Local) !i32 { const func = self._func orelse return FILTER_ACCEPT; return local.toLocal(func).callRethrow(i32, .{node}); } pub fn shouldShow(node: *const Node, what_to_show: u32) bool { // TODO: Test this mapping thoroughly! // nodeType values (1=ELEMENT, 3=TEXT, 9=DOCUMENT, etc.) need to map to // SHOW_* bitmask positions (0x1, 0x4, 0x100, etc.) const node_type_value = node.getNodeType(); const bit_position = node_type_value - 1; const node_type_bit: u32 = @as(u32, 1) << @intCast(bit_position); return (what_to_show & node_type_bit) != 0; } pub const JsApi = struct { pub const bridge = js.Bridge(NodeFilter); pub const Meta = struct { pub const name = "NodeFilter"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; pub const enumerable = false; }; pub const FILTER_ACCEPT = bridge.property(NodeFilter.FILTER_ACCEPT, .{ .template = true }); pub const FILTER_REJECT = bridge.property(NodeFilter.FILTER_REJECT, .{ .template = true }); pub const FILTER_SKIP = bridge.property(NodeFilter.FILTER_SKIP, .{ .template = true }); pub const SHOW_ALL = bridge.property(NodeFilter.SHOW_ALL, .{ .template = true }); pub const SHOW_ELEMENT = bridge.property(NodeFilter.SHOW_ELEMENT, .{ .template = true }); pub const SHOW_ATTRIBUTE = bridge.property(NodeFilter.SHOW_ATTRIBUTE, .{ .template = true }); pub const SHOW_TEXT = bridge.property(NodeFilter.SHOW_TEXT, .{ .template = true }); pub const SHOW_CDATA_SECTION = bridge.property(NodeFilter.SHOW_CDATA_SECTION, .{ .template = true }); pub const SHOW_ENTITY_REFERENCE = bridge.property(NodeFilter.SHOW_ENTITY_REFERENCE, .{ .template = true }); pub const SHOW_ENTITY = bridge.property(NodeFilter.SHOW_ENTITY, .{ .template = true }); pub const SHOW_PROCESSING_INSTRUCTION = bridge.property(NodeFilter.SHOW_PROCESSING_INSTRUCTION, .{ .template = true }); pub const SHOW_COMMENT = bridge.property(NodeFilter.SHOW_COMMENT, .{ .template = true }); pub const SHOW_DOCUMENT = bridge.property(NodeFilter.SHOW_DOCUMENT, .{ .template = true }); pub const SHOW_DOCUMENT_TYPE = bridge.property(NodeFilter.SHOW_DOCUMENT_TYPE, .{ .template = true }); pub const SHOW_DOCUMENT_FRAGMENT = bridge.property(NodeFilter.SHOW_DOCUMENT_FRAGMENT, .{ .template = true }); pub const SHOW_NOTATION = bridge.property(NodeFilter.SHOW_NOTATION, .{ .template = true }); }; ================================================ FILE: src/browser/webapi/Performance.zig ================================================ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const datetime = @import("../../datetime.zig"); pub fn registerTypes() []const type { return &.{ Performance, Entry, Mark, Measure, PerformanceTiming, PerformanceNavigation }; } const std = @import("std"); const Performance = @This(); _time_origin: u64, _entries: std.ArrayList(*Entry) = .{}, _timing: PerformanceTiming = .{}, _navigation: PerformanceNavigation = .{}, /// Get high-resolution timestamp in microseconds, rounded to 5μs increments /// to match browser behavior (prevents fingerprinting) fn highResTimestamp() u64 { const ts = datetime.timespec(); const micros = @as(u64, @intCast(ts.sec)) * 1_000_000 + @as(u64, @intCast(@divTrunc(ts.nsec, 1_000))); // Round to nearest 5 microseconds (like Firefox default) const rounded = @divTrunc(micros + 2, 5) * 5; return rounded; } pub fn init() Performance { return .{ ._time_origin = highResTimestamp(), ._entries = .{}, ._timing = .{}, ._navigation = .{}, }; } pub fn getTiming(self: *Performance) *PerformanceTiming { return &self._timing; } pub fn now(self: *const Performance) f64 { const current = highResTimestamp(); const elapsed = current - self._time_origin; // Return as milliseconds with microsecond precision return @as(f64, @floatFromInt(elapsed)) / 1000.0; } pub fn getTimeOrigin(self: *const Performance) f64 { // Return as milliseconds return @as(f64, @floatFromInt(self._time_origin)) / 1000.0; } pub fn getNavigation(self: *Performance) *PerformanceNavigation { return &self._navigation; } pub fn mark( self: *Performance, name: []const u8, _options: ?Mark.Options, page: *Page, ) !*Mark { const m = try Mark.init(name, _options, page); try self._entries.append(page.arena, m._proto); // Notify about the change. try page.notifyPerformanceObservers(m._proto); return m; } const MeasureOptionsOrStartMark = union(enum) { measure_options: Measure.Options, start_mark: []const u8, }; pub fn measure( self: *Performance, name: []const u8, maybe_options_or_start: ?MeasureOptionsOrStartMark, maybe_end_mark: ?[]const u8, page: *Page, ) !*Measure { if (maybe_options_or_start) |options_or_start| switch (options_or_start) { .measure_options => |options| { // Get start timestamp. const start_timestamp = blk: { if (options.start) |timestamp_or_mark| { break :blk switch (timestamp_or_mark) { .timestamp => |timestamp| timestamp, .mark => |mark_name| try self.getMarkTime(mark_name), }; } break :blk 0.0; }; // Get end timestamp. const end_timestamp = blk: { if (options.end) |timestamp_or_mark| { break :blk switch (timestamp_or_mark) { .timestamp => |timestamp| timestamp, .mark => |mark_name| try self.getMarkTime(mark_name), }; } break :blk self.now(); }; const m = try Measure.init( name, options.detail, start_timestamp, end_timestamp, options.duration, page, ); try self._entries.append(page.arena, m._proto); // Notify about the change. try page.notifyPerformanceObservers(m._proto); return m; }, .start_mark => |start_mark| { // Get start timestamp. const start_timestamp = try self.getMarkTime(start_mark); // Get end timestamp. const end_timestamp = blk: { if (maybe_end_mark) |mark_name| { break :blk try self.getMarkTime(mark_name); } break :blk self.now(); }; const m = try Measure.init( name, null, start_timestamp, end_timestamp, null, page, ); try self._entries.append(page.arena, m._proto); // Notify about the change. try page.notifyPerformanceObservers(m._proto); return m; }, }; const m = try Measure.init(name, null, 0.0, self.now(), null, page); try self._entries.append(page.arena, m._proto); // Notify about the change. try page.notifyPerformanceObservers(m._proto); return m; } pub fn clearMarks(self: *Performance, mark_name: ?[]const u8) void { var i: usize = 0; while (i < self._entries.items.len) { const entry = self._entries.items[i]; if (entry._type == .mark and (mark_name == null or std.mem.eql(u8, entry._name, mark_name.?))) { _ = self._entries.orderedRemove(i); } else { i += 1; } } } pub fn clearMeasures(self: *Performance, measure_name: ?[]const u8) void { var i: usize = 0; while (i < self._entries.items.len) { const entry = self._entries.items[i]; if (entry._type == .measure and (measure_name == null or std.mem.eql(u8, entry._name, measure_name.?))) { _ = self._entries.orderedRemove(i); } else { i += 1; } } } pub fn getEntries(self: *const Performance) []*Entry { return self._entries.items; } pub fn getEntriesByType(self: *const Performance, entry_type: []const u8, page: *Page) ![]const *Entry { var result: std.ArrayList(*Entry) = .empty; for (self._entries.items) |entry| { if (std.mem.eql(u8, entry.getEntryType(), entry_type)) { try result.append(page.call_arena, entry); } } return result.items; } pub fn getEntriesByName(self: *const Performance, name: []const u8, entry_type: ?[]const u8, page: *Page) ![]const *Entry { var result: std.ArrayList(*Entry) = .empty; for (self._entries.items) |entry| { if (!std.mem.eql(u8, entry._name, name)) { continue; } const et = entry_type orelse { try result.append(page.call_arena, entry); continue; }; if (std.mem.eql(u8, entry.getEntryType(), et)) { try result.append(page.call_arena, entry); } } return result.items; } fn getMarkTime(self: *const Performance, mark_name: []const u8) !f64 { for (self._entries.items) |entry| { if (entry._type == .mark and std.mem.eql(u8, entry._name, mark_name)) { return entry._start_time; } } // PerformanceTiming attribute names are valid start/end marks per the // W3C User Timing Level 2 spec. All are relative to navigationStart (= 0). // https://www.w3.org/TR/user-timing/#dom-performance-measure // // `navigationStart` is an equivalent to 0. // Others are dependant to request arrival, end of request etc, but we // return a dummy 0 value for now. const navigation_timing_marks = std.StaticStringMap(void).initComptime(.{ .{ "navigationStart", {} }, .{ "unloadEventStart", {} }, .{ "unloadEventEnd", {} }, .{ "redirectStart", {} }, .{ "redirectEnd", {} }, .{ "fetchStart", {} }, .{ "domainLookupStart", {} }, .{ "domainLookupEnd", {} }, .{ "connectStart", {} }, .{ "connectEnd", {} }, .{ "secureConnectionStart", {} }, .{ "requestStart", {} }, .{ "responseStart", {} }, .{ "responseEnd", {} }, .{ "domLoading", {} }, .{ "domInteractive", {} }, .{ "domContentLoadedEventStart", {} }, .{ "domContentLoadedEventEnd", {} }, .{ "domComplete", {} }, .{ "loadEventStart", {} }, .{ "loadEventEnd", {} }, }); if (navigation_timing_marks.has(mark_name)) { return 0; } return error.SyntaxError; // Mark not found } pub const JsApi = struct { pub const bridge = js.Bridge(Performance); pub const Meta = struct { pub const name = "Performance"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const now = bridge.function(Performance.now, .{}); pub const mark = bridge.function(Performance.mark, .{}); pub const measure = bridge.function(Performance.measure, .{ .dom_exception = true }); pub const clearMarks = bridge.function(Performance.clearMarks, .{}); pub const clearMeasures = bridge.function(Performance.clearMeasures, .{}); pub const getEntries = bridge.function(Performance.getEntries, .{}); pub const getEntriesByType = bridge.function(Performance.getEntriesByType, .{}); pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{}); pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{}); pub const timing = bridge.accessor(Performance.getTiming, null, .{}); pub const navigation = bridge.accessor(Performance.getNavigation, null, .{}); }; pub const Entry = struct { _duration: f64 = 0.0, _type: Type, _name: []const u8, _start_time: f64 = 0.0, pub const Type = union(Enum) { element, event, first_input, @"largest-contentful-paint", @"layout-shift", @"long-animation-frame", longtask, measure: *Measure, navigation, paint, resource, taskattribution, @"visibility-state", mark: *Mark, pub const Enum = enum(u8) { element = 1, // Changing this affect PerformanceObserver's behavior. event = 2, first_input = 3, @"largest-contentful-paint" = 4, @"layout-shift" = 5, @"long-animation-frame" = 6, longtask = 7, measure = 8, navigation = 9, paint = 10, resource = 11, taskattribution = 12, @"visibility-state" = 13, mark = 14, // If we ever have types more than 16, we have to update entry // table of PerformanceObserver too. }; }; pub fn getDuration(self: *const Entry) f64 { return self._duration; } pub fn getEntryType(self: *const Entry) []const u8 { return switch (self._type) { else => |t| @tagName(t), }; } pub fn getName(self: *const Entry) []const u8 { return self._name; } pub fn getStartTime(self: *const Entry) f64 { return self._start_time; } pub const JsApi = struct { pub const bridge = js.Bridge(Entry); pub const Meta = struct { pub const name = "PerformanceEntry"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const name = bridge.accessor(Entry.getName, null, .{}); pub const duration = bridge.accessor(Entry.getDuration, null, .{}); pub const entryType = bridge.accessor(Entry.getEntryType, null, .{}); pub const startTime = bridge.accessor(Entry.getStartTime, null, .{}); }; }; pub const Mark = struct { _proto: *Entry, _detail: ?js.Value.Global, const Options = struct { detail: ?js.Value = null, startTime: ?f64 = null, }; pub fn init(name: []const u8, _opts: ?Options, page: *Page) !*Mark { const opts = _opts orelse Options{}; const start_time = opts.startTime orelse page.window._performance.now(); if (start_time < 0.0) { return error.TypeError; } const detail = if (opts.detail) |d| try d.persist() else null; const m = try page._factory.create(Mark{ ._proto = undefined, ._detail = detail, }); const entry = try page._factory.create(Entry{ ._start_time = start_time, ._name = try page.dupeString(name), ._type = .{ .mark = m }, }); m._proto = entry; return m; } pub fn getDetail(self: *const Mark) ?js.Value.Global { return self._detail; } pub const JsApi = struct { pub const bridge = js.Bridge(Mark); pub const Meta = struct { pub const name = "PerformanceMark"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const detail = bridge.accessor(Mark.getDetail, null, .{}); }; }; pub const Measure = struct { _proto: *Entry, _detail: ?js.Value.Global, const Options = struct { detail: ?js.Value = null, start: ?TimestampOrMark, end: ?TimestampOrMark, duration: ?f64 = null, const TimestampOrMark = union(enum) { timestamp: f64, mark: []const u8, }; }; pub fn init( name: []const u8, maybe_detail: ?js.Value, start_timestamp: f64, end_timestamp: f64, maybe_duration: ?f64, page: *Page, ) !*Measure { const duration = maybe_duration orelse (end_timestamp - start_timestamp); if (duration < 0.0) { return error.TypeError; } const detail = if (maybe_detail) |d| try d.persist() else null; const m = try page._factory.create(Measure{ ._proto = undefined, ._detail = detail, }); const entry = try page._factory.create(Entry{ ._start_time = start_timestamp, ._duration = duration, ._name = try page.dupeString(name), ._type = .{ .measure = m }, }); m._proto = entry; return m; } pub fn getDetail(self: *const Measure) ?js.Value.Global { return self._detail; } pub const JsApi = struct { pub const bridge = js.Bridge(Measure); pub const Meta = struct { pub const name = "PerformanceMeasure"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const detail = bridge.accessor(Measure.getDetail, null, .{}); }; }; /// PerformanceTiming — Navigation Timing Level 1 (legacy, but widely used). /// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming /// All properties return 0 as stub values; the object must not be undefined /// so that scripts accessing performance.timing.navigationStart don't crash. pub const PerformanceTiming = struct { // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, pub const JsApi = struct { pub const bridge = js.Bridge(PerformanceTiming); pub const Meta = struct { pub const name = "PerformanceTiming"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const navigationStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const unloadEventStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const unloadEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const redirectStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const redirectEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const fetchStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const domainLookupStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const domainLookupEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const connectStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const connectEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const secureConnectionStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const requestStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const responseStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const responseEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const domLoading = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const domInteractive = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const domContentLoadedEventStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const domContentLoadedEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const domComplete = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const loadEventStart = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const loadEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); }; }; // PerformanceNavigation implements the Navigation Timing Level 1 API. // https://www.w3.org/TR/navigation-timing/#sec-navigation-navigation-timing-interface // Stub implementation — returns 0 for type (TYPE_NAVIGATE) and 0 for redirectCount. pub const PerformanceNavigation = struct { // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, pub const JsApi = struct { pub const bridge = js.Bridge(PerformanceNavigation); pub const Meta = struct { pub const name = "PerformanceNavigation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const @"type" = bridge.property(0.0, .{ .template = false, .readonly = true }); pub const redirectCount = bridge.property(0.0, .{ .template = false, .readonly = true }); }; }; const testing = @import("../../testing.zig"); test "WebApi: Performance" { try testing.htmlRunner("performance.html", .{}); } ================================================ FILE: src/browser/webapi/PerformanceObserver.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Performance = @import("Performance.zig"); pub fn registerTypes() []const type { return &.{ PerformanceObserver, EntryList }; } /// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver const PerformanceObserver = @This(); /// Emitted when there are events with same interests. _callback: js.Function.Global, /// The threshold to deliver `PerformanceEventTiming` entries. _duration_threshold: f64, /// Entry types we're looking for are encoded as bit flags. _interests: u16, /// Entries this observer hold. /// Don't mutate these; other observers may hold pointers to them. _entries: std.ArrayList(*Performance.Entry), const DefaultDurationThreshold: f64 = 104; /// Creates a new PerformanceObserver object with the given observer callback. pub fn init(callback: js.Function.Global, page: *Page) !*PerformanceObserver { return page._factory.create(PerformanceObserver{ ._callback = callback, ._duration_threshold = DefaultDurationThreshold, ._interests = 0, ._entries = .{}, }); } const ObserveOptions = struct { buffered: bool = false, durationThreshold: f64 = DefaultDurationThreshold, entryTypes: ?[]const []const u8 = null, type: ?[]const u8 = null, }; /// TODO: Support `buffered` option. pub fn observe( self: *PerformanceObserver, maybe_options: ?ObserveOptions, page: *Page, ) !void { const options: ObserveOptions = maybe_options orelse .{}; // Update threshold. self._duration_threshold = @max(@floor(options.durationThreshold / 8) * 8, 16); const entry_types: []const []const u8 = blk: { // More likely. if (options.type) |entry_type| { // Can't have both. if (options.entryTypes != null) { return error.TypeError; } break :blk &.{entry_type}; } if (options.entryTypes) |entry_types| { break :blk entry_types; } return error.TypeError; }; // Update entries. var interests: u16 = 0; for (entry_types) |entry_type| { const fields = @typeInfo(Performance.Entry.Type.Enum).@"enum".fields; inline for (fields) |field| { if (std.mem.eql(u8, field.name, entry_type)) { const flag = @as(u16, 1) << @as(u16, field.value); interests |= flag; } } } // Nothing has updated; no need to go further. if (interests == 0) { return; } // If we had no interests before, it means Page is not aware of // this observer. if (self._interests == 0) { try page.registerPerformanceObserver(self); } // Update interests. self._interests = interests; // Deliver existing entries if buffered option is set. // Per spec, buffered is only valid with the type option, not entryTypes. // Delivery is async via a queued task, not synchronous. if (options.buffered and options.type != null and !self.hasRecords()) { for (page.window._performance._entries.items) |entry| { if (self.interested(entry)) { try self._entries.append(page.arena, entry); } } if (self.hasRecords()) { try page.schedulePerformanceObserverDelivery(); } } } pub fn disconnect(self: *PerformanceObserver, page: *Page) void { page.unregisterPerformanceObserver(self); // Reset observer. self._duration_threshold = DefaultDurationThreshold; self._interests = 0; self._entries.clearRetainingCapacity(); } /// Returns the current list of PerformanceEntry objects /// stored in the performance observer, emptying it out. pub fn takeRecords(self: *PerformanceObserver, page: *Page) ![]*Performance.Entry { // Use page.arena instead of call_arena because this slice is wrapped in EntryList // and may be accessed later. const records = try page.arena.dupe(*Performance.Entry, self._entries.items); self._entries.clearRetainingCapacity(); return records; } pub fn getSupportedEntryTypes() []const []const u8 { return &.{ "mark", "measure" }; } /// Returns true if observer interested with given entry. pub fn interested( self: *const PerformanceObserver, entry: *const Performance.Entry, ) bool { const flag = @as(u16, 1) << @intCast(@intFromEnum(entry._type)); return self._interests & flag != 0; } pub inline fn hasRecords(self: *const PerformanceObserver) bool { return self._entries.items.len > 0; } /// Runs the PerformanceObserver's callback with records; emptying it out. pub fn dispatch(self: *PerformanceObserver, page: *Page) !void { const records = try self.takeRecords(page); var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var caught: js.TryCatch.Caught = undefined; ls.toLocal(self._callback).tryCall(void, .{ EntryList{ ._entries = records }, self }, &caught) catch |err| { log.err(.page, "PerfObserver.dispatch", .{ .err = err, .caught = caught }); return err; }; } pub const JsApi = struct { pub const bridge = js.Bridge(PerformanceObserver); pub const Meta = struct { pub const name = "PerformanceObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(PerformanceObserver.init, .{ .dom_exception = true }); pub const observe = bridge.function(PerformanceObserver.observe, .{ .dom_exception = true }); pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{}); pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{ .dom_exception = true }); pub const supportedEntryTypes = bridge.accessor(PerformanceObserver.getSupportedEntryTypes, null, .{ .static = true }); }; /// List of performance events that were explicitly /// observed via the observe() method. /// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserverEntryList pub const EntryList = struct { _entries: []*Performance.Entry, pub fn getEntries(self: *const EntryList) []*Performance.Entry { return self._entries; } pub const JsApi = struct { pub const bridge = js.Bridge(EntryList); pub const Meta = struct { pub const name = "PerformanceObserverEntryList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const getEntries = bridge.function(EntryList.getEntries, .{}); }; }; const testing = @import("../../testing.zig"); test "WebApi: PerformanceObserver" { try testing.htmlRunner("performance_observer", .{}); } ================================================ FILE: src/browser/webapi/Permissions.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Allocator = std.mem.Allocator; pub fn registerTypes() []const type { return &.{ Permissions, PermissionStatus }; } const Permissions = @This(); // Padding to avoid zero-size struct pointer collisions _pad: bool = false, const QueryDescriptor = struct { name: []const u8, }; // We always report 'prompt' (the default safe value — neither granted nor denied). pub fn query(_: *const Permissions, qd: QueryDescriptor, page: *Page) !js.Promise { const arena = try page.getArena(.{ .debug = "PermissionStatus" }); errdefer page.releaseArena(arena); const status = try arena.create(PermissionStatus); status.* = .{ ._arena = arena, ._state = "prompt", ._name = try arena.dupe(u8, qd.name), }; return page.js.local.?.resolvePromise(status); } const PermissionStatus = struct { _arena: Allocator, _name: []const u8, _state: []const u8, pub fn deinit(self: *PermissionStatus, _: bool, session: *Session) void { session.releaseArena(self._arena); } fn getName(self: *const PermissionStatus) []const u8 { return self._name; } fn getState(self: *const PermissionStatus) []const u8 { return self._state; } pub const JsApi = struct { pub const bridge = js.Bridge(PermissionStatus); pub const Meta = struct { pub const name = "PermissionStatus"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(PermissionStatus.deinit); }; pub const name = bridge.accessor(getName, null, .{}); pub const state = bridge.accessor(getState, null, .{}); }; }; pub const JsApi = struct { pub const bridge = js.Bridge(Permissions); pub const Meta = struct { pub const name = "Permissions"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const query = bridge.function(Permissions.query, .{ .dom_exception = true }); }; ================================================ FILE: src/browser/webapi/PluginArray.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../js/js.zig"); pub fn registerTypes() []const type { return &.{ PluginArray, Plugin }; } const PluginArray = @This(); _pad: bool = false, pub fn refresh(_: *const PluginArray) void {} pub fn getAtIndex(_: *const PluginArray, index: usize) ?*Plugin { _ = index; return null; } pub fn getByName(_: *const PluginArray, name: []const u8) ?*Plugin { _ = name; return null; } // Cannot be constructed, and we currently never return any, so no reason to // implement anything on it (for now) const Plugin = struct { pub const JsApi = struct { pub const bridge = js.Bridge(Plugin); pub const Meta = struct { pub const name = "Plugin"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; }; }; pub const JsApi = struct { pub const bridge = js.Bridge(PluginArray); pub const Meta = struct { pub const name = "PluginArray"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const length = bridge.property(0, .{ .template = false }); pub const refresh = bridge.function(PluginArray.refresh, .{}); pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); fn _item(self: *const PluginArray, index: i32) ?*Plugin { if (index < 0) { return null; } return self.getAtIndex(@intCast(index)); } pub const namedItem = bridge.function(PluginArray.getByName, .{}); }; ================================================ FILE: src/browser/webapi/Range.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const DocumentFragment = @import("DocumentFragment.zig"); const AbstractRange = @import("AbstractRange.zig"); const DOMRect = @import("DOMRect.zig"); const Allocator = std.mem.Allocator; const Range = @This(); _proto: *AbstractRange, pub fn init(page: *Page) !*Range { const arena = try page.getArena(.{ .debug = "Range" }); errdefer page.releaseArena(arena); return page._factory.abstractRange(arena, Range{ ._proto = undefined }, page); } pub fn deinit(self: *Range, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asAbstractRange(self: *Range) *AbstractRange { return self._proto; } pub fn setStart(self: *Range, node: *Node, offset: u32) !void { if (node._type == .document_type) { return error.InvalidNodeType; } if (offset > node.getLength()) { return error.IndexSizeError; } self._proto._start_container = node; self._proto._start_offset = offset; // If start is now after end, or nodes are in different trees, collapse to start const end_root = self._proto._end_container.getRootNode(null); const start_root = node.getRootNode(null); if (end_root != start_root or self._proto.isStartAfterEnd()) { self._proto._end_container = self._proto._start_container; self._proto._end_offset = self._proto._start_offset; } } pub fn setEnd(self: *Range, node: *Node, offset: u32) !void { if (node._type == .document_type) { return error.InvalidNodeType; } // Validate offset if (offset > node.getLength()) { return error.IndexSizeError; } self._proto._end_container = node; self._proto._end_offset = offset; // If end is now before start, or nodes are in different trees, collapse to end const start_root = self._proto._start_container.getRootNode(null); const end_root = node.getRootNode(null); if (start_root != end_root or self._proto.isStartAfterEnd()) { self._proto._start_container = self._proto._end_container; self._proto._start_offset = self._proto._end_offset; } } pub fn setStartBefore(self: *Range, node: *Node) !void { const parent = node.parentNode() orelse return error.InvalidNodeType; const offset = parent.getChildIndex(node) orelse return error.NotFound; try self.setStart(parent, offset); } pub fn setStartAfter(self: *Range, node: *Node) !void { const parent = node.parentNode() orelse return error.InvalidNodeType; const offset = parent.getChildIndex(node) orelse return error.NotFound; try self.setStart(parent, offset + 1); } pub fn setEndBefore(self: *Range, node: *Node) !void { const parent = node.parentNode() orelse return error.InvalidNodeType; const offset = parent.getChildIndex(node) orelse return error.NotFound; try self.setEnd(parent, offset); } pub fn setEndAfter(self: *Range, node: *Node) !void { const parent = node.parentNode() orelse return error.InvalidNodeType; const offset = parent.getChildIndex(node) orelse return error.NotFound; try self.setEnd(parent, offset + 1); } pub fn selectNode(self: *Range, node: *Node) !void { const parent = node.parentNode() orelse return error.InvalidNodeType; const offset = parent.getChildIndex(node) orelse return error.NotFound; try self.setStart(parent, offset); try self.setEnd(parent, offset + 1); } pub fn selectNodeContents(self: *Range, node: *Node) !void { const length = node.getLength(); try self.setStart(node, 0); try self.setEnd(node, length); } pub fn collapse(self: *Range, to_start: ?bool) void { if (to_start orelse true) { self._proto._end_container = self._proto._start_container; self._proto._end_offset = self._proto._start_offset; } else { self._proto._start_container = self._proto._end_container; self._proto._start_offset = self._proto._end_offset; } } pub fn detach(_: *Range) void { // Legacy no-op method kept for backwards compatibility // Modern spec: "The detach() method must do nothing." } pub fn compareBoundaryPoints(self: *const Range, how_raw: i32, source_range: *const Range) !i16 { // Convert how parameter per WebIDL unsigned short conversion // This handles negative numbers and out-of-range values const how_mod = @mod(how_raw, 65536); const how: u16 = if (how_mod < 0) @intCast(@as(i32, how_mod) + 65536) else @intCast(how_mod); // If how is not one of 0, 1, 2, or 3, throw NotSupportedError if (how > 3) { return error.NotSupported; } // If the two ranges' root is different, throw WrongDocumentError const this_root = self._proto._start_container.getRootNode(null); const source_root = source_range._proto._start_container.getRootNode(null); if (this_root != source_root) { return error.WrongDocument; } // Determine which boundary points to compare based on how parameter const result = switch (how) { 0 => AbstractRange.compareBoundaryPoints( // START_TO_START self._proto._start_container, self._proto._start_offset, source_range._proto._start_container, source_range._proto._start_offset, ), 1 => AbstractRange.compareBoundaryPoints( // START_TO_END self._proto._end_container, self._proto._end_offset, source_range._proto._start_container, source_range._proto._start_offset, ), 2 => AbstractRange.compareBoundaryPoints( // END_TO_END self._proto._end_container, self._proto._end_offset, source_range._proto._end_container, source_range._proto._end_offset, ), 3 => AbstractRange.compareBoundaryPoints( // END_TO_START self._proto._start_container, self._proto._start_offset, source_range._proto._end_container, source_range._proto._end_offset, ), else => unreachable, }; return switch (result) { .before => -1, .equal => 0, .after => 1, }; } pub fn comparePoint(self: *const Range, node: *Node, offset: u32) !i16 { // Check if node is in a different tree than the range const node_root = node.getRootNode(null); const start_root = self._proto._start_container.getRootNode(null); if (node_root != start_root) { return error.WrongDocument; } if (node._type == .document_type) { return error.InvalidNodeType; } if (offset > node.getLength()) { return error.IndexSizeError; } // Compare point with start boundary const cmp_start = AbstractRange.compareBoundaryPoints( node, offset, self._proto._start_container, self._proto._start_offset, ); if (cmp_start == .before) { return -1; } const cmp_end = AbstractRange.compareBoundaryPoints( node, offset, self._proto._end_container, self._proto._end_offset, ); return if (cmp_end == .after) 1 else 0; } pub fn isPointInRange(self: *const Range, node: *Node, offset: u32) !bool { // If node's root is different from the context object's root, return false const node_root = node.getRootNode(null); const start_root = self._proto._start_container.getRootNode(null); if (node_root != start_root) { return false; } if (node._type == .document_type) { return error.InvalidNodeType; } // If offset is greater than node's length, throw IndexSizeError if (offset > node.getLength()) { return error.IndexSizeError; } // If (node, offset) is before start or after end, return false const cmp_start = AbstractRange.compareBoundaryPoints( node, offset, self._proto._start_container, self._proto._start_offset, ); if (cmp_start == .before) { return false; } const cmp_end = AbstractRange.compareBoundaryPoints( node, offset, self._proto._end_container, self._proto._end_offset, ); return cmp_end != .after; } pub fn intersectsNode(self: *const Range, node: *Node) bool { // If node's root is different from the context object's root, return false const node_root = node.getRootNode(null); const start_root = self._proto._start_container.getRootNode(null); if (node_root != start_root) { return false; } // Let parent be node's parent const parent = node.parentNode() orelse { // If parent is null, return true return true; }; // Let offset be node's index const offset = parent.getChildIndex(node) orelse { // Should not happen if node has a parent return false; }; // If (parent, offset) is before end and (parent, offset + 1) is after start, return true const before_end = AbstractRange.compareBoundaryPoints( parent, offset, self._proto._end_container, self._proto._end_offset, ); const after_start = AbstractRange.compareBoundaryPoints( parent, offset + 1, self._proto._start_container, self._proto._start_offset, ); if (before_end == .before and after_start == .after) { return true; } // Return false return false; } pub fn cloneRange(self: *const Range, page: *Page) !*Range { const arena = try page.getArena(.{ .debug = "Range.clone" }); errdefer page.releaseArena(arena); const clone = try page._factory.abstractRange(arena, Range{ ._proto = undefined }, page); clone._proto._end_offset = self._proto._end_offset; clone._proto._start_offset = self._proto._start_offset; clone._proto._end_container = self._proto._end_container; clone._proto._start_container = self._proto._start_container; return clone; } pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { // Insert node at the start of the range const container = self._proto._start_container; const offset = self._proto._start_offset; // Per spec: if range is collapsed, end offset should extend to include // the inserted node. Capture before insertion since live range updates // in the insert path will adjust non-collapsed ranges automatically. const was_collapsed = self._proto.getCollapsed(); if (container.is(Node.CData)) |_| { // If container is a text node, we need to split it const parent = container.parentNode() orelse return error.InvalidNodeType; if (offset == 0) { _ = try parent.insertBefore(node, container, page); } else { const text_data = container.getData().str(); if (offset >= text_data.len) { _ = try parent.insertBefore(node, container.nextSibling(), page); } else { // Split the text node into before and after parts const before_text = text_data[0..offset]; const after_text = text_data[offset..]; const before = try page.createTextNode(before_text); const after = try page.createTextNode(after_text); _ = try parent.replaceChild(before, container, page); _ = try parent.insertBefore(node, before.nextSibling(), page); _ = try parent.insertBefore(after, node.nextSibling(), page); } } } else { // Container is an element, insert at offset const ref_child = container.getChildAt(offset); _ = try container.insertBefore(node, ref_child, page); } // Per spec step 11: if range was collapsed, extend end to include inserted node. // Non-collapsed ranges are already handled by the live range update in the insert path. if (was_collapsed) { self._proto._end_offset = self._proto._start_offset + 1; } } pub fn deleteContents(self: *Range, page: *Page) !void { if (self._proto.getCollapsed()) { return; } page.domChanged(); // Simple case: same container if (self._proto._start_container == self._proto._end_container) { if (self._proto._start_container.is(Node.CData)) |cdata| { // Delete part of text node const old_value = cdata.getData(); const text_data = old_value.str(); cdata._data = try String.concat( page.arena, &.{ text_data[0..self._proto._start_offset], text_data[self._proto._end_offset..] }, ); page.characterDataChange(self._proto._start_container, old_value); } else { // Delete child nodes in range. // Capture count before the loop: removeChild triggers live range // updates that decrement _end_offset on each removal. const count = self._proto._end_offset - self._proto._start_offset; var i: u32 = 0; while (i < count) : (i += 1) { if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| { _ = try self._proto._start_container.removeChild(child, page); } } } self.collapse(true); return; } // Complex case: different containers // Handle start container - if it's a text node, truncate it if (self._proto._start_container.is(Node.CData)) |cdata| { const text_data = cdata._data.str(); if (self._proto._start_offset < text_data.len) { // Keep only the part before start_offset const new_text = text_data[0..self._proto._start_offset]; try self._proto._start_container.setData(new_text, page); } } // Handle end container - if it's a text node, truncate it if (self._proto._end_container.is(Node.CData)) |cdata| { const text_data = cdata._data.str(); if (self._proto._end_offset < text_data.len) { // Keep only the part from end_offset onwards const new_text = text_data[self._proto._end_offset..]; try self._proto._end_container.setData(new_text, page); } else if (self._proto._end_offset == text_data.len) { // If we're at the end, set to empty (will be removed if needed) try self._proto._end_container.setData("", page); } } // Remove nodes between start and end containers // For now, handle the common case where they're siblings if (self._proto._start_container.parentNode() == self._proto._end_container.parentNode()) { var current = self._proto._start_container.nextSibling(); while (current != null and current != self._proto._end_container) { const next = current.?.nextSibling(); if (current.?.parentNode()) |parent| { _ = try parent.removeChild(current.?, page); } current = next; } } self.collapse(true); } pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment { const fragment = try DocumentFragment.init(page); if (self._proto.getCollapsed()) return fragment; // Simple case: same container if (self._proto._start_container == self._proto._end_container) { if (self._proto._start_container.is(Node.CData)) |_| { // Clone part of text node const text_data = self._proto._start_container.getData().str(); if (self._proto._start_offset < text_data.len and self._proto._end_offset <= text_data.len) { const cloned_text = text_data[self._proto._start_offset..self._proto._end_offset]; const text_node = try page.createTextNode(cloned_text); _ = try fragment.asNode().appendChild(text_node, page); } } else { // Clone child nodes in range var offset = self._proto._start_offset; while (offset < self._proto._end_offset) : (offset += 1) { if (self._proto._start_container.getChildAt(offset)) |child| { if (try child.cloneNodeForAppending(true, page)) |cloned| { _ = try fragment.asNode().appendChild(cloned, page); } } } } } else { // Complex case: different containers // Clone partial start container if (self._proto._start_container.is(Node.CData)) |_| { const text_data = self._proto._start_container.getData().str(); if (self._proto._start_offset < text_data.len) { // Clone from start_offset to end of text const cloned_text = text_data[self._proto._start_offset..]; const text_node = try page.createTextNode(cloned_text); _ = try fragment.asNode().appendChild(text_node, page); } } // Clone nodes between start and end containers (siblings case) if (self._proto._start_container.parentNode() == self._proto._end_container.parentNode()) { var current = self._proto._start_container.nextSibling(); while (current != null and current != self._proto._end_container) { const next = current.?.nextSibling(); if (try current.?.cloneNodeForAppending(true, page)) |cloned| { _ = try fragment.asNode().appendChild(cloned, page); } current = next; } } // Clone partial end container if (self._proto._end_container.is(Node.CData)) |_| { const text_data = self._proto._end_container.getData().str(); if (self._proto._end_offset > 0 and self._proto._end_offset <= text_data.len) { // Clone from start to end_offset const cloned_text = text_data[0..self._proto._end_offset]; const text_node = try page.createTextNode(cloned_text); _ = try fragment.asNode().appendChild(text_node, page); } } } return fragment; } pub fn extractContents(self: *Range, page: *Page) !*DocumentFragment { const fragment = try self.cloneContents(page); try self.deleteContents(page); return fragment; } pub fn surroundContents(self: *Range, new_parent: *Node, page: *Page) !void { // Extract contents const contents = try self.extractContents(page); // Insert the new parent try self.insertNode(new_parent, page); // Move contents into new parent _ = try new_parent.appendChild(contents.asNode(), page); // Select the new parent's contents try self.selectNodeContents(new_parent); } pub fn createContextualFragment(self: *const Range, html: []const u8, page: *Page) !*DocumentFragment { var context_node = self._proto._start_container; // If start container is a text node, use its parent as context if (context_node.is(Node.CData)) |_| { context_node = context_node.parentNode() orelse context_node; } const fragment = try DocumentFragment.init(page); if (html.len == 0) { return fragment; } // Create a temporary element of the same type as the context for parsing // This preserves the parsing context without modifying the original node const temp_node = if (context_node.is(Node.Element)) |el| try page.createElementNS(el._namespace, el.getTagNameLower(), null) else try page.createElementNS(.html, "div", null); try page.parseHtmlAsChildren(temp_node, html); // Move all parsed children to the fragment // Keep removing first child until temp element is empty const fragment_node = fragment.asNode(); while (temp_node.firstChild()) |child| { page.removeNode(temp_node, child, .{ .will_be_reconnected = true }); try page.appendNode(fragment_node, child, .{ .child_already_connected = false }); } return fragment; } pub fn toString(self: *const Range, page: *Page) ![]const u8 { // Simplified implementation: just extract text content var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.writeTextContent(&buf.writer); return buf.written(); } fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { if (self._proto.getCollapsed()) return; const start_node = self._proto._start_container; const end_node = self._proto._end_container; const start_offset = self._proto._start_offset; const end_offset = self._proto._end_offset; // Same text node — just substring if (start_node == end_node) { if (start_node.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { const data = cdata.getData().str(); const s = @min(start_offset, data.len); const e = @min(end_offset, data.len); try writer.writeAll(data[s..e]); } return; } } const root = self._proto.getCommonAncestorContainer(); // Partial start: if start container is a text node, write from offset to end if (start_node.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { const data = cdata.getData().str(); const s = @min(start_offset, data.len); try writer.writeAll(data[s..]); } } // Walk fully-contained text nodes between the boundaries. // For text containers, the walk starts after that node. // For element containers, the walk starts at the child at offset. const walk_start: ?*Node = if (start_node.is(Node.CData) != null) nextInTreeOrder(start_node, root) else start_node.getChildAt(start_offset) orelse nextAfterSubtree(start_node, root); const walk_end: ?*Node = if (end_node.is(Node.CData) != null) end_node else end_node.getChildAt(end_offset) orelse nextAfterSubtree(end_node, root); if (walk_start) |start| { var current: ?*Node = start; while (current) |n| { if (walk_end) |we| { if (n == we) break; } if (n.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { try writer.writeAll(cdata.getData().str()); } } current = nextInTreeOrder(n, root); } } // Partial end: if end container is a different text node, write from start to offset if (start_node != end_node) { if (end_node.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { const data = cdata.getData().str(); const e = @min(end_offset, data.len); try writer.writeAll(data[0..e]); } } } } fn isCommentOrPI(cdata: *Node.CData) bool { return cdata.is(Node.CData.Comment) != null or cdata.is(Node.CData.ProcessingInstruction) != null; } fn nextInTreeOrder(node: *Node, root: *Node) ?*Node { if (node.firstChild()) |child| return child; return nextAfterSubtree(node, root); } fn nextAfterSubtree(node: *Node, root: *Node) ?*Node { var current = node; while (current != root) { if (current.nextSibling()) |sibling| return sibling; current = current.parentNode() orelse return null; } return null; } pub fn getBoundingClientRect(self: *const Range, page: *Page) DOMRect { if (self._proto.getCollapsed()) { return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 }; } const element = self.getContainerElement() orelse { return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 }; }; return element.getBoundingClientRect(page); } pub fn getClientRects(self: *const Range, page: *Page) ![]DOMRect { if (self._proto.getCollapsed()) { return &.{}; } const element = self.getContainerElement() orelse { return &.{}; }; return element.getClientRects(page); } fn getContainerElement(self: *const Range) ?*Node.Element { const container = self._proto.getCommonAncestorContainer(); if (container.is(Node.Element)) |el| return el; const parent = container.parentNode() orelse return null; return parent.is(Node.Element); } pub const JsApi = struct { pub const bridge = js.Bridge(Range); pub const Meta = struct { pub const name = "Range"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(Range.deinit); }; // Constants for compareBoundaryPoints pub const START_TO_START = bridge.property(0, .{ .template = true }); pub const START_TO_END = bridge.property(1, .{ .template = true }); pub const END_TO_END = bridge.property(2, .{ .template = true }); pub const END_TO_START = bridge.property(3, .{ .template = true }); pub const constructor = bridge.constructor(Range.init, .{}); pub const setStart = bridge.function(Range.setStart, .{ .dom_exception = true }); pub const setEnd = bridge.function(Range.setEnd, .{ .dom_exception = true }); pub const setStartBefore = bridge.function(Range.setStartBefore, .{ .dom_exception = true }); pub const setStartAfter = bridge.function(Range.setStartAfter, .{ .dom_exception = true }); pub const setEndBefore = bridge.function(Range.setEndBefore, .{ .dom_exception = true }); pub const setEndAfter = bridge.function(Range.setEndAfter, .{ .dom_exception = true }); pub const selectNode = bridge.function(Range.selectNode, .{ .dom_exception = true }); pub const selectNodeContents = bridge.function(Range.selectNodeContents, .{}); pub const collapse = bridge.function(Range.collapse, .{ .dom_exception = true }); pub const detach = bridge.function(Range.detach, .{}); pub const compareBoundaryPoints = bridge.function(Range.compareBoundaryPoints, .{ .dom_exception = true }); pub const comparePoint = bridge.function(Range.comparePoint, .{ .dom_exception = true }); pub const isPointInRange = bridge.function(Range.isPointInRange, .{ .dom_exception = true }); pub const intersectsNode = bridge.function(Range.intersectsNode, .{}); pub const cloneRange = bridge.function(Range.cloneRange, .{ .dom_exception = true }); pub const insertNode = bridge.function(Range.insertNode, .{ .dom_exception = true }); pub const deleteContents = bridge.function(Range.deleteContents, .{ .dom_exception = true }); pub const cloneContents = bridge.function(Range.cloneContents, .{ .dom_exception = true }); pub const extractContents = bridge.function(Range.extractContents, .{ .dom_exception = true }); pub const surroundContents = bridge.function(Range.surroundContents, .{ .dom_exception = true }); pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{ .dom_exception = true }); pub const toString = bridge.function(Range.toString, .{ .dom_exception = true }); pub const getBoundingClientRect = bridge.function(Range.getBoundingClientRect, .{}); pub const getClientRects = bridge.function(Range.getClientRects, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: Range" { try testing.htmlRunner("range.html", .{}); } test "WebApi: Range mutations" { try testing.htmlRunner("range_mutations.html", .{}); } ================================================ FILE: src/browser/webapi/ResizeObserver.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const Element = @import("Element.zig"); pub const ResizeObserver = @This(); // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, fn init(cbk: js.Function) ResizeObserver { _ = cbk; return .{}; } const Options = struct { box: []const u8, }; pub fn observe(self: *const ResizeObserver, element: *Element, options_: ?Options) void { _ = self; _ = element; _ = options_; return; } pub fn unobserve(self: *const ResizeObserver, element: *Element) void { _ = self; _ = element; return; } pub fn disconnect(self: *const ResizeObserver) void { _ = self; } pub const JsApi = struct { pub const bridge = js.Bridge(ResizeObserver); pub const Meta = struct { pub const name = "ResizeObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const constructor = bridge.constructor(ResizeObserver.init, .{}); pub const observe = bridge.function(ResizeObserver.observe, .{}); pub const disconnect = bridge.function(ResizeObserver.disconnect, .{}); }; ================================================ FILE: src/browser/webapi/Screen.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const EventTarget = @import("EventTarget.zig"); pub fn registerTypes() []const type { return &.{ Screen, Orientation, }; } const Screen = @This(); _proto: *EventTarget, _orientation: ?*Orientation = null, pub fn asEventTarget(self: *Screen) *EventTarget { return self._proto; } pub fn getOrientation(self: *Screen, page: *Page) !*Orientation { if (self._orientation) |orientation| { return orientation; } const orientation = try Orientation.init(page); self._orientation = orientation; return orientation; } pub const JsApi = struct { pub const bridge = js.Bridge(Screen); pub const Meta = struct { pub const name = "Screen"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const width = bridge.property(1920, .{ .template = false }); pub const height = bridge.property(1080, .{ .template = false }); pub const availWidth = bridge.property(1920, .{ .template = false }); pub const availHeight = bridge.property(1040, .{ .template = false }); pub const colorDepth = bridge.property(24, .{ .template = false }); pub const pixelDepth = bridge.property(24, .{ .template = false }); pub const orientation = bridge.accessor(Screen.getOrientation, null, .{}); }; pub const Orientation = struct { _proto: *EventTarget, pub fn init(page: *Page) !*Orientation { return page._factory.eventTarget(Orientation{ ._proto = undefined, }); } pub fn asEventTarget(self: *Orientation) *EventTarget { return self._proto; } pub const JsApi = struct { pub const bridge = js.Bridge(Orientation); pub const Meta = struct { pub const name = "ScreenOrientation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const angle = bridge.property(0, .{ .template = false }); pub const @"type" = bridge.property("landscape-primary", .{ .template = false }); }; }; ================================================ FILE: src/browser/webapi/Selection.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../log.zig"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Range = @import("Range.zig"); const AbstractRange = @import("AbstractRange.zig"); const Node = @import("Node.zig"); const Event = @import("Event.zig"); const Document = @import("Document.zig"); /// https://w3c.github.io/selection-api/ const Selection = @This(); pub const SelectionDirection = enum { backward, forward, none }; _range: ?*Range = null, _direction: SelectionDirection = .none, pub const init: Selection = .{}; pub fn deinit(self: *Selection, shutdown: bool, session: *Session) void { if (self._range) |r| { r.deinit(shutdown, session); self._range = null; } } fn dispatchSelectionChangeEvent(page: *Page) !void { const event = try Event.init("selectionchange", .{}, page); try page._event_manager.dispatch(page.document.asEventTarget(), event); } fn isInTree(self: *const Selection) bool { if (self._range == null) { return false; } const anchor_node = self.getAnchorNode() orelse return false; const focus_node = self.getFocusNode() orelse return false; return anchor_node.isConnected() and focus_node.isConnected(); } pub fn getAnchorNode(self: *const Selection) ?*Node { const range = self._range orelse return null; const node = switch (self._direction) { .backward => range.asAbstractRange().getEndContainer(), .forward, .none => range.asAbstractRange().getStartContainer(), }; return if (node.isConnected()) node else null; } pub fn getAnchorOffset(self: *const Selection) u32 { const range = self._range orelse return 0; const anchor_node = self.getAnchorNode() orelse return 0; if (!anchor_node.isConnected()) return 0; return switch (self._direction) { .backward => range.asAbstractRange().getEndOffset(), .forward, .none => range.asAbstractRange().getStartOffset(), }; } pub fn getDirection(self: *const Selection) []const u8 { return @tagName(self._direction); } pub fn getFocusNode(self: *const Selection) ?*Node { const range = self._range orelse return null; const node = switch (self._direction) { .backward => range.asAbstractRange().getStartContainer(), .forward, .none => range.asAbstractRange().getEndContainer(), }; return if (node.isConnected()) node else null; } pub fn getFocusOffset(self: *const Selection) u32 { const range = self._range orelse return 0; const focus_node = self.getFocusNode() orelse return 0; if (!focus_node.isConnected()) return 0; return switch (self._direction) { .backward => range.asAbstractRange().getStartOffset(), .forward, .none => range.asAbstractRange().getEndOffset(), }; } pub fn getIsCollapsed(self: *const Selection) bool { const range = self._range orelse return true; return range.asAbstractRange().getCollapsed(); } pub fn getRangeCount(self: *const Selection) u32 { if (self._range == null) { return 0; } if (!self.isInTree()) { return 0; } return 1; } pub fn getType(self: *const Selection) []const u8 { if (self._range == null) { return "None"; } if (!self.isInTree()) { return "None"; } if (self.getIsCollapsed()) { return "Caret"; } return "Range"; } pub fn addRange(self: *Selection, range: *Range, page: *Page) !void { if (self._range != null) { return; } // Only add the range if its root node is in the document associated with this selection const start_node = range.asAbstractRange().getStartContainer(); if (!page.document.asNode().contains(start_node)) { return; } self.setRange(range, page); try dispatchSelectionChangeEvent(page); } pub fn removeRange(self: *Selection, range: *Range, page: *Page) !void { const existing_range = self._range orelse return error.NotFound; if (existing_range != range) { return error.NotFound; } self.setRange(null, page); try dispatchSelectionChangeEvent(page); } pub fn removeAllRanges(self: *Selection, page: *Page) !void { if (self._range == null) { return; } self.setRange(null, page); self._direction = .none; try dispatchSelectionChangeEvent(page); } pub fn collapseToEnd(self: *Selection, page: *Page) !void { const range = self._range orelse return; const abstract = range.asAbstractRange(); const last_node = abstract.getEndContainer(); const last_offset = abstract.getEndOffset(); const new_range = try Range.init(page); try new_range.setStart(last_node, last_offset); try new_range.setEnd(last_node, last_offset); self.setRange(new_range, page); self._direction = .none; try dispatchSelectionChangeEvent(page); } pub fn collapseToStart(self: *Selection, page: *Page) !void { const range = self._range orelse return error.InvalidStateError; const abstract = range.asAbstractRange(); const first_node = abstract.getStartContainer(); const first_offset = abstract.getStartOffset(); const new_range = try Range.init(page); try new_range.setStart(first_node, first_offset); try new_range.setEnd(first_node, first_offset); self.setRange(new_range, page); self._direction = .none; try dispatchSelectionChangeEvent(page); } pub fn containsNode(self: *const Selection, node: *Node, partial: bool) !bool { const range = self._range orelse return false; if (partial) { if (range.intersectsNode(node)) { return true; } } else { const abstract = range.asAbstractRange(); if (abstract.getStartContainer() == node or abstract.getEndContainer() == node) { return false; } const parent = node.parentNode() orelse return false; const offset = parent.getChildIndex(node) orelse return false; const start_cmp = range.comparePoint(parent, offset) catch return false; const end_cmp = range.comparePoint(parent, offset + 1) catch return false; if (start_cmp <= 0 and end_cmp >= 0) { return true; } } return false; } pub fn deleteFromDocument(self: *Selection, page: *Page) !void { const range = self._range orelse return; try range.deleteContents(page); try dispatchSelectionChangeEvent(page); } pub fn extend(self: *Selection, node: *Node, _offset: ?u32, page: *Page) !void { const range = self._range orelse return error.InvalidState; const offset = _offset orelse 0; // If the node is not contained in the document, do not change the selection if (!page.document.asNode().contains(node)) { return; } if (node._type == .document_type) return error.InvalidNodeType; if (offset > node.getLength()) { return error.IndexSizeError; } const old_anchor = switch (self._direction) { .backward => range.asAbstractRange().getEndContainer(), .forward, .none => range.asAbstractRange().getStartContainer(), }; const old_anchor_offset = switch (self._direction) { .backward => range.asAbstractRange().getEndOffset(), .forward, .none => range.asAbstractRange().getStartOffset(), }; const new_range = try Range.init(page); const cmp = AbstractRange.compareBoundaryPoints(node, offset, old_anchor, old_anchor_offset); switch (cmp) { .before => { try new_range.setStart(node, offset); try new_range.setEnd(old_anchor, old_anchor_offset); self._direction = .backward; }, .after => { try new_range.setStart(old_anchor, old_anchor_offset); try new_range.setEnd(node, offset); self._direction = .forward; }, .equal => { try new_range.setStart(old_anchor, old_anchor_offset); try new_range.setEnd(old_anchor, old_anchor_offset); self._direction = .none; }, } self.setRange(new_range, page); try dispatchSelectionChangeEvent(page); } pub fn getRangeAt(self: *Selection, index: u32) !*Range { if (index != 0) return error.IndexSizeError; if (!self.isInTree()) return error.IndexSizeError; const range = self._range orelse return error.IndexSizeError; return range; } const ModifyAlter = enum { move, extend, pub fn fromString(str: []const u8) ?ModifyAlter { return std.meta.stringToEnum(ModifyAlter, str); } }; const ModifyDirection = enum { forward, backward, left, right, pub fn fromString(str: []const u8) ?ModifyDirection { return std.meta.stringToEnum(ModifyDirection, str); } }; const ModifyGranularity = enum { character, word, // The rest are either: // 1. Layout dependent. // 2. Not widely supported across browsers. pub fn fromString(str: []const u8) ?ModifyGranularity { return std.meta.stringToEnum(ModifyGranularity, str); } }; pub fn modify( self: *Selection, alter_str: []const u8, direction_str: []const u8, granularity_str: []const u8, page: *Page, ) !void { const alter = ModifyAlter.fromString(alter_str) orelse return; const direction = ModifyDirection.fromString(direction_str) orelse return; const granularity = ModifyGranularity.fromString(granularity_str) orelse return; const range = self._range orelse return; const is_forward = switch (direction) { .forward, .right => true, .backward, .left => false, }; switch (granularity) { .character => try self.modifyByCharacter(alter, is_forward, range, page), .word => try self.modifyByWord(alter, is_forward, range, page), } } fn isTextNode(node: *const Node) bool { return switch (node._type) { .cdata => |cd| cd._type == .text, else => false, }; } fn nextTextNode(node: *Node) ?*Node { var current = node; while (true) { if (current.firstChild()) |child| { current = child; } else if (current.nextSibling()) |sib| { current = sib; } else { while (true) { const parent = current.parentNode() orelse return null; if (parent.nextSibling()) |uncle| { current = uncle; break; } current = parent; } } if (isTextNode(current)) return current; } } fn nextTextNodeAfter(node: *Node) ?*Node { var current = node; while (true) { if (current.nextSibling()) |sib| { current = sib; } else { while (true) { const parent = current.parentNode() orelse return null; if (parent.nextSibling()) |uncle| { current = uncle; break; } current = parent; } } var descend = current; while (true) { if (isTextNode(descend)) return descend; descend = descend.firstChild() orelse break; } } } fn prevTextNode(node: *Node) ?*Node { var current = node; while (true) { if (current.previousSibling()) |sib| { current = sib; while (current.lastChild()) |child| { current = child; } } else { current = current.parentNode() orelse return null; } if (isTextNode(current)) return current; } } fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void { const abstract = range.asAbstractRange(); const focus_node = switch (self._direction) { .backward => abstract.getStartContainer(), .forward, .none => abstract.getEndContainer(), }; const focus_offset = switch (self._direction) { .backward => abstract.getStartOffset(), .forward, .none => abstract.getEndOffset(), }; var new_node = focus_node; var new_offset = focus_offset; if (isTextNode(focus_node)) { if (forward) { const len = focus_node.getLength(); if (focus_offset < len) { new_offset += 1; } else if (nextTextNode(focus_node)) |next| { new_node = next; new_offset = 0; } } else { if (focus_offset > 0) { new_offset -= 1; } else if (prevTextNode(focus_node)) |prev| { new_node = prev; new_offset = prev.getLength(); } } } else { if (forward) { if (focus_node.getChildAt(focus_offset)) |child| { if (isTextNode(child)) { new_node = child; new_offset = 0; } else if (nextTextNode(child)) |t| { new_node = t; new_offset = 0; } } else if (nextTextNodeAfter(focus_node)) |next| { new_node = next; new_offset = 1; } } else { // backward element-node case var idx = focus_offset; while (idx > 0) { idx -= 1; const child = focus_node.getChildAt(idx) orelse break; var bottom = child; while (bottom.lastChild()) |c| bottom = c; if (isTextNode(bottom)) { new_node = bottom; new_offset = bottom.getLength(); break; } } } } try self.applyModify(alter, new_node, new_offset, page); } fn isWordChar(c: u8) bool { return std.ascii.isAlphanumeric(c) or c == '_'; } fn nextWordEnd(text: []const u8, offset: u32) u32 { var i = offset; // consumes whitespace till next word while (i < text.len and !isWordChar(text[i])) : (i += 1) {} // consumes next word while (i < text.len and isWordChar(text[i])) : (i += 1) {} return i; } fn prevWordStart(text: []const u8, offset: u32) u32 { var i = offset; if (i > 0) i -= 1; // consumes the white space while (i > 0 and !isWordChar(text[i])) : (i -= 1) {} // consumes the last word while (i > 0 and isWordChar(text[i - 1])) : (i -= 1) {} return i; } fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void { const abstract = range.asAbstractRange(); const focus_node = switch (self._direction) { .backward => abstract.getStartContainer(), .forward, .none => abstract.getEndContainer(), }; const focus_offset = switch (self._direction) { .backward => abstract.getStartOffset(), .forward, .none => abstract.getEndOffset(), }; var new_node = focus_node; var new_offset = focus_offset; if (isTextNode(focus_node)) { if (forward) { const i = nextWordEnd(new_node.getData().str(), new_offset); if (i > new_offset) { new_offset = i; } else if (nextTextNode(focus_node)) |next| { new_node = next; new_offset = nextWordEnd(next.getData().str(), 0); } } else { const i = prevWordStart(new_node.getData().str(), new_offset); if (i < new_offset) { new_offset = i; } else if (prevTextNode(focus_node)) |prev| { new_node = prev; new_offset = prevWordStart(prev.getData().str(), @intCast(prev.getData().len)); } } } else { // Search and apply rules on the next Text Node. // This is either next (on forward) or previous (on backward). if (forward) { const child = focus_node.getChildAt(focus_offset) orelse { if (nextTextNodeAfter(focus_node)) |next| { new_node = next; new_offset = nextWordEnd(next.getData().str(), 0); } return self.applyModify(alter, new_node, new_offset, page); }; const t = if (isTextNode(child)) child else nextTextNode(child) orelse { return self.applyModify(alter, new_node, new_offset, page); }; new_node = t; new_offset = nextWordEnd(t.getData().str(), 0); } else { var idx = focus_offset; while (idx > 0) { idx -= 1; const child = focus_node.getChildAt(idx) orelse break; var bottom = child; while (bottom.lastChild()) |c| bottom = c; if (isTextNode(bottom)) { new_node = bottom; new_offset = prevWordStart(bottom.getData().str(), bottom.getLength()); break; } } } } try self.applyModify(alter, new_node, new_offset, page); } fn applyModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void { switch (alter) { .move => { const new_range = try Range.init(page); try new_range.setStart(new_node, new_offset); try new_range.setEnd(new_node, new_offset); self.setRange(new_range, page); self._direction = .none; try dispatchSelectionChangeEvent(page); }, .extend => try self.extend(new_node, new_offset, page), } } pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void { if (parent._type == .document_type) return error.InvalidNodeType; // If the node is not contained in the document, do not change the selection if (!page.document.asNode().contains(parent)) { return; } const range = try Range.init(page); try range.setStart(parent, 0); const child_count = parent.getChildrenCount(); try range.setEnd(parent, @intCast(child_count)); self.setRange(range, page); self._direction = .forward; try dispatchSelectionChangeEvent(page); } pub fn setBaseAndExtent( self: *Selection, anchor_node: *Node, anchor_offset: u32, focus_node: *Node, focus_offset: u32, page: *Page, ) !void { if (anchor_offset > anchor_node.getLength()) { return error.IndexSizeError; } if (focus_offset > focus_node.getLength()) { return error.IndexSizeError; } const cmp = AbstractRange.compareBoundaryPoints( anchor_node, anchor_offset, focus_node, focus_offset, ); const range = try Range.init(page); switch (cmp) { .before => { try range.setStart(anchor_node, anchor_offset); try range.setEnd(focus_node, focus_offset); self._direction = .forward; }, .after => { try range.setStart(focus_node, focus_offset); try range.setEnd(anchor_node, anchor_offset); self._direction = .backward; }, .equal => { try range.setStart(anchor_node, anchor_offset); try range.setEnd(anchor_node, anchor_offset); self._direction = .none; }, } self.setRange(range, page); try dispatchSelectionChangeEvent(page); } pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !void { const node = _node orelse { try self.removeAllRanges(page); return; }; if (node._type == .document_type) return error.InvalidNodeType; const offset = _offset orelse 0; if (offset > node.getLength()) { return error.IndexSizeError; } // If the node is not contained in the document, do not change the selection if (!page.document.asNode().contains(node)) { return; } const range = try Range.init(page); try range.setStart(node, offset); try range.setEnd(node, offset); self.setRange(range, page); self._direction = .none; try dispatchSelectionChangeEvent(page); } pub fn toString(self: *const Selection, page: *Page) ![]const u8 { const range = self._range orelse return ""; return try range.toString(page); } fn setRange(self: *Selection, new_range: ?*Range, page: *Page) void { if (self._range) |existing| { existing.deinit(false, page._session); } if (new_range) |nr| { nr.asAbstractRange().acquireRef(); } self._range = new_range; } pub const JsApi = struct { pub const bridge = js.Bridge(Selection); pub const Meta = struct { pub const name = "Selection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const finalizer = bridge.finalizer(Selection.deinit); }; pub const anchorNode = bridge.accessor(Selection.getAnchorNode, null, .{}); pub const anchorOffset = bridge.accessor(Selection.getAnchorOffset, null, .{}); pub const direction = bridge.accessor(Selection.getDirection, null, .{}); pub const focusNode = bridge.accessor(Selection.getFocusNode, null, .{}); pub const focusOffset = bridge.accessor(Selection.getFocusOffset, null, .{}); pub const isCollapsed = bridge.accessor(Selection.getIsCollapsed, null, .{}); pub const rangeCount = bridge.accessor(Selection.getRangeCount, null, .{}); pub const @"type" = bridge.accessor(Selection.getType, null, .{}); pub const addRange = bridge.function(Selection.addRange, .{}); pub const collapse = bridge.function(Selection.collapse, .{ .dom_exception = true }); pub const collapseToEnd = bridge.function(Selection.collapseToEnd, .{}); pub const collapseToStart = bridge.function(Selection.collapseToStart, .{ .dom_exception = true }); pub const containsNode = bridge.function(Selection.containsNode, .{}); pub const deleteFromDocument = bridge.function(Selection.deleteFromDocument, .{}); pub const empty = bridge.function(Selection.removeAllRanges, .{}); pub const extend = bridge.function(Selection.extend, .{ .dom_exception = true }); // unimplemented: getComposedRanges pub const getRangeAt = bridge.function(Selection.getRangeAt, .{ .dom_exception = true }); pub const modify = bridge.function(Selection.modify, .{}); pub const removeAllRanges = bridge.function(Selection.removeAllRanges, .{}); pub const removeRange = bridge.function(Selection.removeRange, .{ .dom_exception = true }); pub const selectAllChildren = bridge.function(Selection.selectAllChildren, .{ .dom_exception = true }); pub const setBaseAndExtent = bridge.function(Selection.setBaseAndExtent, .{ .dom_exception = true }); pub const setPosition = bridge.function(Selection.collapse, .{ .dom_exception = true }); pub const toString = bridge.function(Selection.toString, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: Selection" { try testing.htmlRunner("selection.html", .{}); } ================================================ FILE: src/browser/webapi/ShadowRoot.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const DocumentFragment = @import("DocumentFragment.zig"); const Element = @import("Element.zig"); const ShadowRoot = @This(); pub const Mode = enum { open, closed, pub fn fromString(str: []const u8) !Mode { return std.meta.stringToEnum(Mode, str) orelse error.InvalidMode; } }; _proto: *DocumentFragment, _mode: Mode, _host: *Element, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .{}, _removed_ids: std.StringHashMapUnmanaged(void) = .{}, _adopted_style_sheets: ?js.Object.Global = null, pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot { return page._factory.documentFragment(ShadowRoot{ ._proto = undefined, ._mode = mode, ._host = host, }); } pub fn asDocumentFragment(self: *ShadowRoot) *DocumentFragment { return self._proto; } pub fn asNode(self: *ShadowRoot) *Node { return self._proto.asNode(); } pub fn asEventTarget(self: *ShadowRoot) *@import("EventTarget.zig") { return self.asNode().asEventTarget(); } pub fn getMode(self: *const ShadowRoot) []const u8 { return @tagName(self._mode); } pub fn getHost(self: *const ShadowRoot) *Element { return self._host; } pub fn getElementById(self: *ShadowRoot, id: []const u8, page: *Page) ?*Element { if (id.len == 0) { return null; } // Fast path: ID is in the map if (self._elements_by_id.get(id)) |element| { return element; } // Slow path: ID was removed but might have duplicates if (self._removed_ids.remove(id)) { // Do a tree walk to find another element with this ID var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{}); while (tw.next()) |el| { const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse continue; if (std.mem.eql(u8, element_id, id)) { // we ignore this error to keep getElementById easy to call // if it really failed, then we're out of memory and nothing's // going to work like it should anyways. const owned_id = page.dupeString(id) catch return null; self._elements_by_id.put(page.arena, owned_id, el) catch return null; return el; } } } return null; } pub fn getAdoptedStyleSheets(self: *ShadowRoot, page: *Page) !js.Object.Global { if (self._adopted_style_sheets) |ass| { return ass; } const js_arr = page.js.local.?.newArray(0); const js_obj = js_arr.toObject(); self._adopted_style_sheets = try js_obj.persist(); return self._adopted_style_sheets.?; } pub fn setAdoptedStyleSheets(self: *ShadowRoot, sheets: js.Object) !void { self._adopted_style_sheets = try sheets.persist(); } pub const JsApi = struct { pub const bridge = js.Bridge(ShadowRoot); pub const Meta = struct { pub const name = "ShadowRoot"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{}); pub const host = bridge.accessor(ShadowRoot.getHost, null, .{}); pub const getElementById = bridge.function(_getElementById, .{}); fn _getElementById(self: *ShadowRoot, value_: ?js.Value, page: *Page) !?*Element { const value = value_ orelse return null; if (value.isNull()) { return self.getElementById("null", page); } if (value.isUndefined()) { return self.getElementById("undefined", page); } return self.getElementById(try value.toZig([]const u8), page); } pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: ShadowRoot" { try testing.htmlRunner("shadowroot", .{}); } ================================================ FILE: src/browser/webapi/StorageManager.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); pub fn registerTypes() []const type { return &.{ StorageManager, StorageEstimate }; } const StorageManager = @This(); _pad: bool = false, pub fn estimate(_: *const StorageManager, page: *Page) !js.Promise { const est = try page._factory.create(StorageEstimate{ ._usage = 0, ._quota = 1024 * 1024 * 1024, // 1 GiB }); return page.js.local.?.resolvePromise(est); } const StorageEstimate = struct { _quota: u64, _usage: u64, fn getUsage(self: *const StorageEstimate) u64 { return self._usage; } fn getQuota(self: *const StorageEstimate) u64 { return self._quota; } pub const JsApi = struct { pub const bridge = js.Bridge(StorageEstimate); pub const Meta = struct { pub const name = "StorageEstimate"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const quota = bridge.accessor(getQuota, null, .{}); pub const usage = bridge.accessor(getUsage, null, .{}); }; }; pub const JsApi = struct { pub const bridge = js.Bridge(StorageManager); pub const Meta = struct { pub const name = "StorageManager"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const estimate = bridge.function(StorageManager.estimate, .{}); }; ================================================ FILE: src/browser/webapi/SubtleCrypto.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const log = @import("../../log.zig"); const crypto = @import("../../crypto.zig"); const DOMException = @import("DOMException.zig"); const Page = @import("../Page.zig"); const js = @import("../js/js.zig"); pub fn registerTypes() []const type { return &.{ SubtleCrypto, CryptoKey }; } /// The SubtleCrypto interface of the Web Crypto API provides a number of low-level /// cryptographic functions. /// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto /// https://w3c.github.io/webcrypto/#subtlecrypto-interface const SubtleCrypto = @This(); /// Don't optimize away the type. _pad: bool = false, const Algorithm = union(enum) { /// For RSASSA-PKCS1-v1_5, RSA-PSS, or RSA-OAEP: pass an RsaHashedKeyGenParams object. rsa_hashed_key_gen: RsaHashedKeyGen, /// For HMAC: pass an HmacKeyGenParams object. hmac_key_gen: HmacKeyGen, /// Can be Ed25519 or X25519. name: []const u8, /// Can be Ed25519 or X25519. object: struct { name: []const u8 }, /// https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams const RsaHashedKeyGen = struct { name: []const u8, /// This should be at least 2048. /// Some organizations are now recommending that it should be 4096. modulusLength: u32, publicExponent: js.TypedArray(u8), hash: union(enum) { string: []const u8, object: struct { name: []const u8 }, }, }; /// https://developer.mozilla.org/en-US/docs/Web/API/HmacKeyGenParams const HmacKeyGen = struct { /// Always HMAC. name: []const u8, /// Its also possible to pass this in an object. hash: union(enum) { string: []const u8, object: struct { name: []const u8 }, }, /// If omitted, default is the block size of the chosen hash function. length: ?usize, }; /// Alias. const HmacImport = HmacKeyGen; const EcdhKeyDeriveParams = struct { /// Can be Ed25519 or X25519. name: []const u8, public: *const CryptoKey, }; /// Algorithm for deriveBits() and deriveKey(). const DeriveBits = union(enum) { ecdh_or_x25519: EcdhKeyDeriveParams, }; }; /// Generate a new key (for symmetric algorithms) or key pair (for public-key algorithms). pub fn generateKey( _: *const SubtleCrypto, algorithm: Algorithm, extractable: bool, key_usages: []const []const u8, page: *Page, ) !js.Promise { const key_or_pair = CryptoKey.init(algorithm, extractable, key_usages, page) catch |err| { return page.js.local.?.rejectPromise(@errorName(err)); }; return page.js.local.?.resolvePromise(key_or_pair); } /// Exports a key: that is, it takes as input a CryptoKey object and gives you /// the key in an external, portable format. pub fn exportKey( _: *const SubtleCrypto, format: []const u8, key: *CryptoKey, page: *Page, ) !js.Promise { if (!key.canExportKey()) { return error.InvalidAccessError; } if (std.mem.eql(u8, format, "raw")) { return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = key._key }); } const is_unsupported = std.mem.eql(u8, format, "pkcs8") or std.mem.eql(u8, format, "spki") or std.mem.eql(u8, format, "jwk"); if (is_unsupported) { log.warn(.not_implemented, "SubtleCrypto.exportKey", .{ .format = format }); } return page.js.local.?.rejectPromise(@errorName(error.NotSupported)); } /// Derive a secret key from a master key. pub fn deriveBits( _: *const SubtleCrypto, algorithm: Algorithm.DeriveBits, base_key: *const CryptoKey, // Private key. length: usize, page: *Page, ) !js.Promise { return switch (algorithm) { .ecdh_or_x25519 => |p| { const name = p.name; if (std.mem.eql(u8, name, "X25519")) { return page.js.local.?.resolvePromise(base_key.deriveBitsX25519(p.public, length, page)); } if (std.mem.eql(u8, name, "ECDH")) { log.warn(.not_implemented, "SubtleCrypto.deriveBits", .{ .name = name }); } return page.js.local.?.rejectPromise(@errorName(error.NotSupported)); }, }; } const SignatureAlgorithm = union(enum) { string: []const u8, object: struct { name: []const u8 }, pub fn isHMAC(self: SignatureAlgorithm) bool { const name = switch (self) { .string => |string| string, .object => |object| object.name, }; if (name.len < 4) return false; const hmac: u32 = @bitCast([4]u8{ 'H', 'M', 'A', 'C' }); return @as(u32, @bitCast(name[0..4].*)) == hmac; } }; /// Generate a digital signature. pub fn sign( _: *const SubtleCrypto, /// This can either be provided as string or object. /// We can't use the `Algorithm` type defined before though since there /// are couple of changes between the two. /// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign#algorithm algorithm: SignatureAlgorithm, key: *CryptoKey, data: []const u8, // ArrayBuffer. page: *Page, ) !js.Promise { return switch (key._type) { .hmac => { // Verify algorithm. if (!algorithm.isHMAC()) { return page.js.local.?.rejectPromise(@errorName(error.InvalidAccessError)); } // Call sign for HMAC. const result = key.signHMAC(data, page) catch |err| { return page.js.local.?.rejectPromise(@errorName(err)); }; return page.js.local.?.resolvePromise(result); }, else => { log.warn(.not_implemented, "SubtleCrypto.sign", .{ .key_type = key._type }); return page.js.local.?.rejectPromise(@errorName(error.InvalidAccessError)); }, }; } /// Verify a digital signature. pub fn verify( _: *const SubtleCrypto, algorithm: SignatureAlgorithm, key: *const CryptoKey, signature: []const u8, // ArrayBuffer. data: []const u8, // ArrayBuffer. page: *Page, ) !js.Promise { if (!algorithm.isHMAC()) return error.InvalidAccessError; return switch (key._type) { .hmac => key.verifyHMAC(signature, data, page), else => return error.InvalidAccessError, }; } pub fn digest(_: *const SubtleCrypto, algorithm: []const u8, data: js.TypedArray(u8), page: *Page) !js.Promise { const local = page.js.local.?; if (algorithm.len > 10) { return local.rejectPromise(DOMException.fromError(error.NotSupported)); } const normalized = std.ascii.lowerString(&page.buf, algorithm); if (std.mem.eql(u8, normalized, "sha-1")) { const Sha1 = std.crypto.hash.Sha1; Sha1.hash(data.values, page.buf[0..Sha1.digest_length], .{}); return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha1.digest_length] }); } if (std.mem.eql(u8, normalized, "sha-256")) { const Sha256 = std.crypto.hash.sha2.Sha256; Sha256.hash(data.values, page.buf[0..Sha256.digest_length], .{}); return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha256.digest_length] }); } if (std.mem.eql(u8, normalized, "sha-384")) { const Sha384 = std.crypto.hash.sha2.Sha384; Sha384.hash(data.values, page.buf[0..Sha384.digest_length], .{}); return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha384.digest_length] }); } if (std.mem.eql(u8, normalized, "sha-512")) { const Sha512 = std.crypto.hash.sha2.Sha512; Sha512.hash(data.values, page.buf[0..Sha512.digest_length], .{}); return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha512.digest_length] }); } return local.rejectPromise(DOMException.fromError(error.NotSupported)); } /// Returns the desired digest by its name. fn findDigest(name: []const u8) error{Invalid}!*const crypto.EVP_MD { if (std.mem.eql(u8, "SHA-256", name)) { return crypto.EVP_sha256(); } if (std.mem.eql(u8, "SHA-384", name)) { return crypto.EVP_sha384(); } if (std.mem.eql(u8, "SHA-512", name)) { return crypto.EVP_sha512(); } if (std.mem.eql(u8, "SHA-1", name)) { return crypto.EVP_sha1(); } return error.Invalid; } const KeyOrPair = union(enum) { key: *CryptoKey, pair: CryptoKeyPair }; /// https://developer.mozilla.org/en-US/docs/Web/API/CryptoKeyPair const CryptoKeyPair = struct { privateKey: *CryptoKey, publicKey: *CryptoKey, }; /// Represents a cryptographic key obtained from one of the SubtleCrypto methods /// generateKey(), deriveKey(), importKey(), or unwrapKey(). pub const CryptoKey = struct { /// Algorithm being used. _type: Type, /// Whether the key is extractable. _extractable: bool, /// Bit flags of `usages`; see `Usages` type. _usages: u8, /// Raw bytes of key. _key: []const u8, /// Different algorithms may use different data structures; /// this union can be used for such situations. Active field is understood /// from `_type`. _vary: extern union { /// Used by HMAC. digest: *const crypto.EVP_MD, /// Used by asymmetric algorithms (X25519, Ed25519). pkey: *crypto.EVP_PKEY, }, pub const Type = enum(u8) { hmac, rsa, x25519 }; /// Changing the names of fields would affect bitmask creation. pub const Usages = struct { // zig fmt: off pub const encrypt = 0x001; pub const decrypt = 0x002; pub const sign = 0x004; pub const verify = 0x008; pub const deriveKey = 0x010; pub const deriveBits = 0x020; pub const wrapKey = 0x040; pub const unwrapKey = 0x080; // zig fmt: on }; pub fn init( algorithm: Algorithm, extractable: bool, key_usages: []const []const u8, page: *Page, ) !KeyOrPair { return switch (algorithm) { .hmac_key_gen => |hmac| initHMAC(hmac, extractable, key_usages, page), .name => |name| { if (std.mem.eql(u8, "X25519", name)) { return initX25519(extractable, key_usages, page); } log.warn(.not_implemented, "CryptoKey.init", .{ .name = name }); return error.NotSupported; }, .object => |object| { // Ditto. const name = object.name; if (std.mem.eql(u8, "X25519", name)) { return initX25519(extractable, key_usages, page); } log.warn(.not_implemented, "CryptoKey.init", .{ .name = name }); return error.NotSupported; }, else => { log.warn(.not_implemented, "CryptoKey.init", .{ .algorithm = algorithm }); return error.NotSupported; }, }; } inline fn canSign(self: *const CryptoKey) bool { return self._usages & Usages.sign != 0; } inline fn canVerify(self: *const CryptoKey) bool { return self._usages & Usages.verify != 0; } inline fn canDeriveBits(self: *const CryptoKey) bool { return self._usages & Usages.deriveBits != 0; } inline fn canExportKey(self: *const CryptoKey) bool { return self._extractable; } /// Only valid for HMAC. inline fn getDigest(self: *const CryptoKey) *const crypto.EVP_MD { return self._vary.digest; } /// Only valid for asymmetric algorithms (X25519, Ed25519). inline fn getKeyObject(self: *const CryptoKey) *crypto.EVP_PKEY { return self._vary.pkey; } // HMAC. fn initHMAC( algorithm: Algorithm.HmacKeyGen, extractable: bool, key_usages: []const []const u8, page: *Page, ) !KeyOrPair { const hash = switch (algorithm.hash) { .string => |str| str, .object => |obj| obj.name, }; // Find digest. const d = try findDigest(hash); // We need at least a single usage. if (key_usages.len == 0) { return error.SyntaxError; } // Calculate usages mask. const decls = @typeInfo(Usages).@"struct".decls; var usages_mask: u8 = 0; iter_usages: for (key_usages) |usage| { inline for (decls) |decl| { if (std.mem.eql(u8, decl.name, usage)) { usages_mask |= @field(Usages, decl.name); continue :iter_usages; } } // Unknown usage if got here. return error.SyntaxError; } const block_size: usize = blk: { // Caller provides this in bits, not bytes. if (algorithm.length) |length| { break :blk length / 8; } // Prefer block size of the hash function instead. break :blk crypto.EVP_MD_block_size(d); }; const key = try page.arena.alloc(u8, block_size); errdefer page.arena.free(key); // HMAC is simply CSPRNG. const res = crypto.RAND_bytes(key.ptr, key.len); lp.assert(res == 1, "SubtleCrypto.initHMAC", .{ .res = res }); const crypto_key = try page._factory.create(CryptoKey{ ._type = .hmac, ._extractable = extractable, ._usages = usages_mask, ._key = key, ._vary = .{ .digest = d }, }); return .{ .key = crypto_key }; } fn signHMAC(self: *const CryptoKey, data: []const u8, page: *Page) !js.ArrayBuffer { if (!self.canSign()) { return error.InvalidAccessError; } const buffer = try page.call_arena.alloc(u8, crypto.EVP_MD_size(self.getDigest())); errdefer page.call_arena.free(buffer); var out_len: u32 = 0; // Try to sign. const signed = crypto.HMAC( self.getDigest(), @ptrCast(self._key.ptr), self._key.len, data.ptr, data.len, buffer.ptr, &out_len, ); if (signed != null) { return js.ArrayBuffer{ .values = buffer[0..out_len] }; } // Not DOM exception, failed on our side. return error.Invalid; } fn verifyHMAC( self: *const CryptoKey, signature: []const u8, data: []const u8, page: *Page, ) !js.Promise { if (!self.canVerify()) { return error.InvalidAccessError; } var buffer: [crypto.EVP_MAX_MD_BLOCK_SIZE]u8 = undefined; var out_len: u32 = 0; // Try to sign. const signed = crypto.HMAC( self.getDigest(), @ptrCast(self._key.ptr), self._key.len, data.ptr, data.len, &buffer, &out_len, ); if (signed != null) { // CRYPTO_memcmp compare in constant time so prohibits time-based attacks. const res = crypto.CRYPTO_memcmp(signed, @ptrCast(signature.ptr), signature.len); return page.js.local.?.resolvePromise(res == 0); } return page.js.local.?.resolvePromise(false); } // X25519. /// Create a pair of X25519. fn initX25519( extractable: bool, key_usages: []const []const u8, page: *Page, ) !KeyOrPair { // This code has too many allocations here and there, might be nice to // gather them together with a single alloc call. Not sure if factory // pattern is suitable for it though. // Calculate usages; only matters for private key. // Only deriveKey() and deriveBits() be used for X25519. if (key_usages.len == 0) { return error.SyntaxError; } var mask: u8 = 0; iter_usages: for (key_usages) |usage| { inline for ([_][]const u8{ "deriveKey", "deriveBits" }) |name| { if (std.mem.eql(u8, name, usage)) { mask |= @field(Usages, name); continue :iter_usages; } } // Unknown usage if got here. return error.SyntaxError; } const public_value = try page.arena.alloc(u8, crypto.X25519_PUBLIC_VALUE_LEN); errdefer page.arena.free(public_value); const private_key = try page.arena.alloc(u8, crypto.X25519_PRIVATE_KEY_LEN); errdefer page.arena.free(private_key); // There's no info about whether this can fail; so I assume it cannot. crypto.X25519_keypair(@ptrCast(public_value), @ptrCast(private_key)); // Create EVP_PKEY for public key. // Seems we can use `EVP_PKEY_from_raw_private_key` for this, Chrome // prefer not to, yet BoringSSL added it and recommends instead of what // we're doing currently. const public_pkey = crypto.EVP_PKEY_new_raw_public_key( crypto.EVP_PKEY_X25519, null, public_value.ptr, public_value.len, ); if (public_pkey == null) { return error.OutOfMemory; } // Create EVP_PKEY for private key. // Seems we can use `EVP_PKEY_from_raw_private_key` for this, Chrome // prefer not to, yet BoringSSL added it and recommends instead of what // we're doing currently. const private_pkey = crypto.EVP_PKEY_new_raw_private_key( crypto.EVP_PKEY_X25519, null, private_key.ptr, private_key.len, ); if (private_pkey == null) { return error.OutOfMemory; } const private = try page._factory.create(CryptoKey{ ._type = .x25519, ._extractable = extractable, ._usages = mask, ._key = private_key, ._vary = .{ .pkey = private_pkey.? }, }); errdefer page._factory.destroy(private); const public = try page._factory.create(CryptoKey{ ._type = .x25519, // Public keys are always extractable. ._extractable = true, // Always empty for public key. ._usages = 0, ._key = public_value, ._vary = .{ .pkey = public_pkey.? }, }); errdefer page._factory.destroy(public); return .{ .pair = .{ .privateKey = private, .publicKey = public } }; } fn deriveBitsX25519( private: *const CryptoKey, public: *const CryptoKey, length_in_bits: usize, page: *Page, ) !js.ArrayBuffer { if (!private.canDeriveBits()) { return error.InvalidAccessError; } const maybe_ctx = crypto.EVP_PKEY_CTX_new(private.getKeyObject(), null); if (maybe_ctx) |ctx| { // Context is valid, free it on failure. errdefer crypto.EVP_PKEY_CTX_free(ctx); // Init derive operation and set public key as peer. if (crypto.EVP_PKEY_derive_init(ctx) != 1 or crypto.EVP_PKEY_derive_set_peer(ctx, public.getKeyObject()) != 1) { // Failed on our end. return error.Internal; } const derived_key = try page.call_arena.alloc(u8, 32); errdefer page.call_arena.free(derived_key); var out_key_len: usize = derived_key.len; const result = crypto.EVP_PKEY_derive(ctx, derived_key.ptr, &out_key_len); if (result != 1) { // Failed on our end. return error.Internal; } // Sanity check. lp.assert(derived_key.len == out_key_len, "SubtleCrypto.deriveBitsX25519", .{}); // Length is in bits, convert to byte length. const length = (length_in_bits / 8) + (7 + (length_in_bits % 8)) / 8; // Truncate the slice to specified length. // Same as `derived_key`. const tailored = blk: { if (length > derived_key.len) { return error.LengthTooLong; } break :blk derived_key[0..length]; }; // Zero any "unused bits" in the final byte. const remainder_bits: u3 = @intCast(length_in_bits % 8); if (remainder_bits != 0) { tailored[tailored.len - 1] &= ~(@as(u8, 0xFF) >> remainder_bits); } return js.ArrayBuffer{ .values = tailored }; } // Failed on our end. return error.Internal; } pub const JsApi = struct { pub const bridge = js.Bridge(CryptoKey); pub const Meta = struct { pub const name = "CryptoKey"; pub var class_id: bridge.ClassId = undefined; pub const prototype_chain = bridge.prototypeChain(); }; }; }; pub const JsApi = struct { pub const bridge = js.Bridge(SubtleCrypto); pub const Meta = struct { pub const name = "SubtleCrypto"; pub var class_id: bridge.ClassId = undefined; pub const prototype_chain = bridge.prototypeChain(); }; pub const generateKey = bridge.function(SubtleCrypto.generateKey, .{ .dom_exception = true }); pub const exportKey = bridge.function(SubtleCrypto.exportKey, .{ .dom_exception = true }); pub const sign = bridge.function(SubtleCrypto.sign, .{ .dom_exception = true }); pub const verify = bridge.function(SubtleCrypto.verify, .{ .dom_exception = true }); pub const deriveBits = bridge.function(SubtleCrypto.deriveBits, .{ .dom_exception = true }); pub const digest = bridge.function(SubtleCrypto.digest, .{ .dom_exception = true }); }; ================================================ FILE: src/browser/webapi/TreeWalker.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const Node = @import("Node.zig"); const Element = @import("Element.zig"); pub const Full = TreeWalker(.full); pub const FullExcludeSelf = TreeWalker(.exclude_self); pub const Children = TreeWalker(.children); const Mode = enum { full, children, exclude_self, }; pub fn TreeWalker(comptime mode: Mode) type { return struct { _current: ?*Node = null, _next: ?*Node, _root: *Node, const Self = @This(); const Opts = struct {}; pub fn init(root: *Node, opts: Opts) Self { _ = opts; return .{ ._next = firstNext(root), ._root = root, }; } pub fn next(self: *Self) ?*Node { const node = self._next orelse return null; self._current = node; if (comptime mode == .children) { self._next = node.nextSibling(); return node; } if (node.firstChild()) |child| { self._next = child; } else { var current: *Node = node; while (current != self._root) { if (current.nextSibling()) |sibling| { self._next = sibling; return node; } current = current._parent orelse break; } self._next = null; } return node; } pub fn skipChildren(self: *Self) void { if (comptime mode == .children) return; const current_node = self._current orelse return; var current: *Node = current_node; while (current != self._root) { if (current.nextSibling()) |sibling| { self._next = sibling; return; } current = current._parent orelse break; } self._next = null; } pub fn reset(self: *Self) void { self._current = null; self._next = firstNext(self._root); } pub fn contains(self: *const Self, target: *const Node) bool { const root = self._root; if (comptime mode == .children) { var it = root.childrenIterator(); while (it.next()) |child| { if (child == target) { return true; } } return false; } var node = target; if ((comptime mode == .exclude_self) and node == root) { return false; } while (true) { if (node == root) { return true; } node = node._parent orelse return false; } } pub fn clone(self: *const Self) Self { const root = self._root; return .{ ._next = firstNext(root), ._root = root, }; } fn firstNext(root: *Node) ?*Node { return switch (comptime mode) { .full => root, .exclude_self => root.firstChild(), .children => root.firstChild(), }; } pub const Elements = struct { tw: Self, pub fn init(root: *Node, comptime opts: Opts) Elements { return .{ .tw = Self.init(root, opts), }; } pub fn next(self: *Elements) ?*Element { while (self.tw.next()) |node| { if (node.is(Element)) |el| { return el; } } return null; } pub fn reset(self: *Elements) void { self.tw.reset(); } }; }; } test "TreeWalker: skipChildren" { const testing = @import("../../testing.zig"); const page = try testing.test_session.createPage(); defer testing.test_session.removePage(); const doc = page.window._document; // <div> // <span> // <b>A</b> // </span> // <p>B</p> // </div> const div = try doc.createElement("div", null, page); const span = try doc.createElement("span", null, page); const b = try doc.createElement("b", null, page); const p = try doc.createElement("p", null, page); _ = try span.asNode().appendChild(b.asNode(), page); _ = try div.asNode().appendChild(span.asNode(), page); _ = try div.asNode().appendChild(p.asNode(), page); var tw = Full.init(div.asNode(), .{}); // root (div) try testing.expect(tw.next() == div.asNode()); // span try testing.expect(tw.next() == span.asNode()); // skip children of span (should jump over <b> to <p>) tw.skipChildren(); try testing.expect(tw.next() == p.asNode()); try testing.expect(tw.next() == null); } ================================================ FILE: src/browser/webapi/URL.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const U = @import("../URL.zig"); const Page = @import("../Page.zig"); const URLSearchParams = @import("net/URLSearchParams.zig"); const Blob = @import("Blob.zig"); const Allocator = std.mem.Allocator; const URL = @This(); _raw: [:0]const u8, _arena: ?Allocator = null, _search_params: ?*URLSearchParams = null, // convenience pub const resolve = @import("../URL.zig").resolve; pub const eqlDocument = @import("../URL.zig").eqlDocument; pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); const base = if (base_) |b| blk: { // If URL is absolute, base is ignored (but we still use page.url internally) if (url_is_absolute) { break :blk page.url; } // For relative URLs, base must be a valid absolute URL if (!@import("../URL.zig").isCompleteHTTPUrl(b)) { return error.TypeError; } break :blk b; } else if (!url_is_absolute) { return error.TypeError; } else page.url; const arena = page.arena; const raw = try resolve(arena, base, url, .{ .always_dupe = true }); return page._factory.create(URL{ ._raw = raw, ._arena = arena, }); } pub fn getUsername(self: *const URL) []const u8 { return U.getUsername(self._raw); } pub fn setUsername(self: *URL, value: []const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setUsername(self._raw, value, allocator); } pub fn getPassword(self: *const URL) []const u8 { return U.getPassword(self._raw); } pub fn setPassword(self: *URL, value: []const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setPassword(self._raw, value, allocator); } pub fn getPathname(self: *const URL) []const u8 { return U.getPathname(self._raw); } pub fn getProtocol(self: *const URL) []const u8 { return U.getProtocol(self._raw); } pub fn getHostname(self: *const URL) []const u8 { return U.getHostname(self._raw); } pub fn getHost(self: *const URL) []const u8 { return U.getHost(self._raw); } pub fn getPort(self: *const URL) []const u8 { return U.getPort(self._raw); } pub fn getOrigin(self: *const URL, page: *const Page) ![]const u8 { return (try U.getOrigin(page.call_arena, self._raw)) orelse { // yes, a null string, that's what the spec wants return "null"; }; } pub fn getSearch(self: *const URL, page: *const Page) ![]const u8 { // If searchParams has been accessed, generate search from it if (self._search_params) |sp| { if (sp.getSize() == 0) { return ""; } var buf = std.Io.Writer.Allocating.init(page.call_arena); try buf.writer.writeByte('?'); try sp.toString(&buf.writer); return buf.written(); } return U.getSearch(self._raw); } pub fn getHash(self: *const URL) []const u8 { return U.getHash(self._raw); } pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams { if (self._search_params) |sp| { return sp; } // Get current search string (without the '?') const search = try self.getSearch(page); const search_value = if (search.len > 0) search[1..] else ""; const params = try URLSearchParams.init(.{ .query_string = search_value }, page); self._search_params = params; return params; } pub fn setHref(self: *URL, value: []const u8, page: *Page) !void { const base = if (U.isCompleteHTTPUrl(value)) page.url else self._raw; const raw = try U.resolve(self._arena orelse page.arena, base, value, .{ .always_dupe = true }); self._raw = raw; // Update existing searchParams if it exists if (self._search_params) |sp| { const search = U.getSearch(raw); const search_value = if (search.len > 0) search[1..] else ""; try sp.updateFromString(search_value, page); } } pub fn setProtocol(self: *URL, value: []const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setProtocol(self._raw, value, allocator); } pub fn setHost(self: *URL, value: []const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setHost(self._raw, value, allocator); } pub fn setHostname(self: *URL, value: []const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setHostname(self._raw, value, allocator); } pub fn setPort(self: *URL, value: ?[]const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setPort(self._raw, value, allocator); } pub fn setPathname(self: *URL, value: []const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setPathname(self._raw, value, allocator); } pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setSearch(self._raw, value, allocator); // Update existing searchParams if it exists if (self._search_params) |sp| { const search = U.getSearch(self._raw); const search_value = if (search.len > 0) search[1..] else ""; try sp.updateFromString(search_value, page); } } pub fn setHash(self: *URL, value: []const u8) !void { const allocator = self._arena orelse return error.NoAllocator; self._raw = try U.setHash(self._raw, value, allocator); } pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 { const sp = self._search_params orelse { return self._raw; }; // Rebuild URL from searchParams const raw = self._raw; // Find the base (everything before ? or #) const base_end = std.mem.indexOfAnyPos(u8, raw, 0, "?#") orelse raw.len; const base = raw[0..base_end]; // Get the hash if it exists const hash = self.getHash(); // Build the new URL string var buf = std.Io.Writer.Allocating.init(page.call_arena); try buf.writer.writeAll(base); // Add / if missing (e.g., "https://example.com" -> "https://example.com/") // Only add if pathname is just "/" and not already in the base const pathname = U.getPathname(raw); if (std.mem.eql(u8, pathname, "/") and !std.mem.endsWith(u8, base, "/")) { try buf.writer.writeByte('/'); } // Only add ? if there are params if (sp.getSize() > 0) { try buf.writer.writeByte('?'); try sp.toString(&buf.writer); } try buf.writer.writeAll(hash); try buf.writer.writeByte(0); return buf.written()[0 .. buf.written().len - 1 :0]; } pub fn canParse(url: []const u8, base_: ?[]const u8) bool { if (base_) |b| { return U.isCompleteHTTPUrl(b); } return U.isCompleteHTTPUrl(url); } pub fn createObjectURL(blob: *Blob, page: *Page) ![]const u8 { var uuid_buf: [36]u8 = undefined; @import("../../id.zig").uuidv4(&uuid_buf); const blob_url = try std.fmt.allocPrint( page.arena, "blob:{s}/{s}", .{ page.origin orelse "null", uuid_buf }, ); try page._blob_urls.put(page.arena, blob_url, blob); // prevent GC from cleaning up the blob while it's in the registry page.js.strongRef(blob); return blob_url; } pub fn revokeObjectURL(url: []const u8, page: *Page) void { // Per spec: silently ignore non-blob URLs if (!std.mem.startsWith(u8, url, "blob:")) { return; } // Remove from registry and release strong ref (no-op if not found) if (page._blob_urls.fetchRemove(url)) |entry| { page.js.weakRef(entry.value); } } pub const JsApi = struct { pub const bridge = js.Bridge(URL); pub const Meta = struct { pub const name = "URL"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(URL.init, .{}); pub const canParse = bridge.function(URL.canParse, .{ .static = true }); pub const createObjectURL = bridge.function(URL.createObjectURL, .{ .static = true }); pub const revokeObjectURL = bridge.function(URL.revokeObjectURL, .{ .static = true }); pub const toString = bridge.function(URL.toString, .{}); pub const toJSON = bridge.function(URL.toString, .{}); pub const href = bridge.accessor(URL.toString, URL.setHref, .{}); pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{}); pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{}); pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{}); pub const username = bridge.accessor(URL.getUsername, URL.setUsername, .{}); pub const password = bridge.accessor(URL.getPassword, URL.setPassword, .{}); pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{}); pub const host = bridge.accessor(URL.getHost, URL.setHost, .{}); pub const port = bridge.accessor(URL.getPort, URL.setPort, .{}); pub const origin = bridge.accessor(URL.getOrigin, null, .{}); pub const protocol = bridge.accessor(URL.getProtocol, URL.setProtocol, .{}); pub const searchParams = bridge.accessor(URL.getSearchParams, null, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: URL" { try testing.htmlRunner("url.html", .{}); } ================================================ FILE: src/browser/webapi/VisualViewport.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const EventTarget = @import("EventTarget.zig"); const Window = @import("Window.zig"); const VisualViewport = @This(); _proto: *EventTarget, pub fn asEventTarget(self: *VisualViewport) *EventTarget { return self._proto; } pub fn getPageLeft(_: *const VisualViewport, page: *Page) u32 { return page.window.getScrollX(); } pub fn getPageTop(_: *const VisualViewport, page: *Page) u32 { return page.window.getScrollY(); } pub const JsApi = struct { pub const bridge = js.Bridge(VisualViewport); pub const Meta = struct { pub const name = "VisualViewport"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; // Static viewport properties for headless browser // No pinch-zoom or mobile viewport, so values are straightforward pub const offsetLeft = bridge.property(0, .{ .template = false }); pub const offsetTop = bridge.property(0, .{ .template = false }); pub const pageLeft = bridge.accessor(VisualViewport.getPageLeft, null, .{}); pub const pageTop = bridge.accessor(VisualViewport.getPageTop, null, .{}); pub const width = bridge.property(1920, .{ .template = false }); pub const height = bridge.property(1080, .{ .template = false }); pub const scale = bridge.property(1.0, .{ .template = false }); }; ================================================ FILE: src/browser/webapi/Window.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const builtin = @import("builtin"); const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); const Navigation = @import("navigation/Navigation.zig"); const Crypto = @import("Crypto.zig"); const CSS = @import("CSS.zig"); const Navigator = @import("Navigator.zig"); const Screen = @import("Screen.zig"); const VisualViewport = @import("VisualViewport.zig"); const Performance = @import("Performance.zig"); const Document = @import("Document.zig"); const Location = @import("Location.zig"); const Fetch = @import("net/Fetch.zig"); const Event = @import("Event.zig"); const EventTarget = @import("EventTarget.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const MediaQueryList = @import("css/MediaQueryList.zig"); const storage = @import("storage/storage.zig"); const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); const Selection = @import("Selection.zig"); const IS_DEBUG = builtin.mode == .Debug; const Allocator = std.mem.Allocator; const Window = @This(); _proto: *EventTarget, _page: *Page, _document: *Document, _css: CSS = .init, _crypto: Crypto = .init, _console: Console = .init, _navigator: Navigator = .init, _screen: *Screen, _visual_viewport: *VisualViewport, _performance: Performance, _storage_bucket: storage.Bucket = .{}, _on_load: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, _on_error: ?js.Function.Global = null, _on_message: ?js.Function.Global = null, _on_rejection_handled: ?js.Function.Global = null, _on_unhandled_rejection: ?js.Function.Global = null, _current_event: ?*Event = null, _location: *Location, _timer_id: u30 = 0, _timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{}, _custom_elements: CustomElementRegistry = .{}, _scroll_pos: struct { x: u32, y: u32, state: enum { scroll, end, done, }, } = .{ .x = 0, .y = 0, .state = .done, }, pub fn asEventTarget(self: *Window) *EventTarget { return self._proto; } pub fn getEvent(self: *const Window) ?*Event { return self._current_event; } pub fn getSelf(self: *Window) *Window { return self; } pub fn getWindow(self: *Window) *Window { return self; } pub fn getTop(self: *Window) *Window { var p = self._page; while (p.parent) |parent| { p = parent; } return p.window; } pub fn getParent(self: *Window) *Window { if (self._page.parent) |p| { return p.window; } return self; } pub fn getDocument(self: *Window) *Document { return self._document; } pub fn getConsole(self: *Window) *Console { return &self._console; } pub fn getNavigator(self: *Window) *Navigator { return &self._navigator; } pub fn getScreen(self: *Window) *Screen { return self._screen; } pub fn getVisualViewport(self: *const Window) *VisualViewport { return self._visual_viewport; } pub fn getCrypto(self: *Window) *Crypto { return &self._crypto; } pub fn getCSS(self: *Window) *CSS { return &self._css; } pub fn getPerformance(self: *Window) *Performance { return &self._performance; } pub fn getLocalStorage(self: *Window) *storage.Lookup { return &self._storage_bucket.local; } pub fn getSessionStorage(self: *Window) *storage.Lookup { return &self._storage_bucket.session; } pub fn getLocation(self: *const Window) *Location { return self._location; } pub fn getSelection(self: *const Window) *Selection { return &self._document._selection; } pub fn setLocation(self: *Window, url: [:0]const u8, page: *Page) !void { return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._page }); } pub fn getHistory(_: *Window, page: *Page) *History { return &page._session.history; } pub fn getNavigation(_: *Window, page: *Page) *Navigation { return &page._session.navigation; } pub fn getCustomElements(self: *Window) *CustomElementRegistry { return &self._custom_elements; } pub fn getOnLoad(self: *const Window) ?js.Function.Global { return self._on_load; } pub fn setOnLoad(self: *Window, setter: ?FunctionSetter) void { self._on_load = getFunctionFromSetter(setter); } pub fn getOnPageShow(self: *const Window) ?js.Function.Global { return self._on_pageshow; } pub fn setOnPageShow(self: *Window, setter: ?FunctionSetter) void { self._on_pageshow = getFunctionFromSetter(setter); } pub fn getOnPopState(self: *const Window) ?js.Function.Global { return self._on_popstate; } pub fn setOnPopState(self: *Window, setter: ?FunctionSetter) void { self._on_popstate = getFunctionFromSetter(setter); } pub fn getOnError(self: *const Window) ?js.Function.Global { return self._on_error; } pub fn setOnError(self: *Window, setter: ?FunctionSetter) void { self._on_error = getFunctionFromSetter(setter); } pub fn getOnMessage(self: *const Window) ?js.Function.Global { return self._on_message; } pub fn setOnMessage(self: *Window, setter: ?FunctionSetter) void { self._on_message = getFunctionFromSetter(setter); } pub fn getOnRejectionHandled(self: *const Window) ?js.Function.Global { return self._on_rejection_handled; } pub fn setOnRejectionHandled(self: *Window, setter: ?FunctionSetter) void { self._on_rejection_handled = getFunctionFromSetter(setter); } pub fn getOnUnhandledRejection(self: *const Window) ?js.Function.Global { return self._on_unhandled_rejection; } pub fn setOnUnhandledRejection(self: *Window, setter: ?FunctionSetter) void { self._on_unhandled_rejection = getFunctionFromSetter(setter); } pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, page: *Page) !js.Promise { return Fetch.init(input, options, page); } pub fn setTimeout(self: *Window, cb: js.Function.Temp, delay_ms: ?u32, params: []js.Value.Temp, page: *Page) !u32 { return self.scheduleCallback(cb, delay_ms orelse 0, .{ .repeat = false, .params = params, .low_priority = false, .name = "window.setTimeout", }, page); } pub fn setInterval(self: *Window, cb: js.Function.Temp, delay_ms: ?u32, params: []js.Value.Temp, page: *Page) !u32 { return self.scheduleCallback(cb, delay_ms orelse 0, .{ .repeat = true, .params = params, .low_priority = false, .name = "window.setInterval", }, page); } pub fn setImmediate(self: *Window, cb: js.Function.Temp, params: []js.Value.Temp, page: *Page) !u32 { return self.scheduleCallback(cb, 0, .{ .repeat = false, .params = params, .low_priority = false, .name = "window.setImmediate", }, page); } pub fn requestAnimationFrame(self: *Window, cb: js.Function.Temp, page: *Page) !u32 { return self.scheduleCallback(cb, 5, .{ .repeat = false, .params = &.{}, .low_priority = false, .mode = .animation_frame, .name = "window.requestAnimationFrame", }, page); } pub fn queueMicrotask(_: *Window, cb: js.Function, page: *Page) void { page.js.queueMicrotaskFunc(cb); } pub fn clearTimeout(self: *Window, id: u32) void { var sc = self._timers.get(id) orelse return; sc.removed = true; } pub fn clearInterval(self: *Window, id: u32) void { var sc = self._timers.get(id) orelse return; sc.removed = true; } pub fn clearImmediate(self: *Window, id: u32) void { var sc = self._timers.get(id) orelse return; sc.removed = true; } pub fn cancelAnimationFrame(self: *Window, id: u32) void { var sc = self._timers.get(id) orelse return; sc.removed = true; } const RequestIdleCallbackOpts = struct { timeout: ?u32 = null, }; pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestIdleCallbackOpts, page: *Page) !u32 { const opts = opts_ orelse RequestIdleCallbackOpts{}; return self.scheduleCallback(cb, opts.timeout orelse 50, .{ .mode = .idle, .repeat = false, .params = &.{}, .low_priority = true, .name = "window.requestIdleCallback", }, page); } pub fn cancelIdleCallback(self: *Window, id: u32) void { var sc = self._timers.get(id) orelse return; sc.removed = true; } pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ .@"error" = try err.temp(), .message = err.toStringSlice() catch "Unknown error", .bubbles = false, .cancelable = true, }, page); // Invoke window.onerror callback if set (per WHATWG spec, this is called // with 5 arguments: message, source, lineno, colno, error) // If it returns true, the event is cancelled. var prevent_default = false; if (self._on_error) |on_error| { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); const local_func = ls.toLocal(on_error); const result = local_func.call(js.Value, .{ error_event._message, error_event._filename, error_event._line_number, error_event._column_number, err, }) catch null; // Per spec: returning true from onerror cancels the event if (result) |r| { prevent_default = r.isTrue(); } } const event = error_event.asEvent(); event._prevent_default = prevent_default; // Pass null as handler: onerror was already called above with 5 args. // We still dispatch so that addEventListener('error', ...) listeners fire. try page._event_manager.dispatchDirect(self.asEventTarget(), event, null, .{ .context = "window.reportError", }); if (comptime builtin.is_test == false) { if (!event._prevent_default) { log.warn(.js, "window.reportError", .{ .message = error_event._message, .filename = error_event._filename, .line_number = error_event._line_number, .column_number = error_event._column_number, }); } } } pub fn matchMedia(_: *const Window, query: []const u8, page: *Page) !*MediaQueryList { return page._factory.eventTarget(MediaQueryList{ ._proto = undefined, ._media = try page.dupeString(query), }); } pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]const u8, page: *Page) !*CSSStyleProperties { if (pseudo_element) |pe| { if (pe.len != 0) { log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pe }); } } return CSSStyleProperties.init(element, true, page); } pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, page: *Page) !void { // For now, we ignore targetOrigin checking and just dispatch the message // In a full implementation, we would validate the origin _ = target_origin; // self = the window that will get the message // page = the context calling postMessage const target_page = self._page; const source_window = target_page.js.getIncumbent().window; const arena = try target_page.getArena(.{ .debug = "Window.postMessage" }); errdefer target_page.releaseArena(arena); // Origin should be the source window's origin (where the message came from) const origin = try source_window._location.getOrigin(page); const callback = try arena.create(PostMessageCallback); callback.* = .{ .arena = arena, .message = message, .page = target_page, .source = source_window, .origin = try arena.dupe(u8, origin), }; try target_page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "postMessage", .low_priority = false, .finalizer = PostMessageCallback.cancelled, }); } pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 { const encoded_len = std.base64.standard.Encoder.calcSize(input.len); const encoded = try page.call_arena.alloc(u8, encoded_len); return std.base64.standard.Encoder.encode(encoded, input); } pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace); // Forgiving base64 decode per WHATWG spec: // https://infra.spec.whatwg.org/#forgiving-base64-decode // Remove trailing padding to use standard_no_pad decoder const unpadded = std.mem.trimRight(u8, trimmed, "="); // Length % 4 == 1 is invalid (can't represent valid base64) if (unpadded.len % 4 == 1) { return error.InvalidCharacterError; } const decoded_len = std.base64.standard_no_pad.Decoder.calcSizeForSlice(unpadded) catch return error.InvalidCharacterError; const decoded = try page.call_arena.alloc(u8, decoded_len); std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError; return decoded; } pub fn structuredClone(_: *const Window, value: js.Value) !js.Value { return value.structuredClone(); } pub fn getFrame(self: *Window, idx: usize) !?*Window { const page = self._page; const frames = page.frames.items; if (idx >= frames.len) { return null; } if (page.frames_sorted == false) { std.mem.sort(*Page, frames, {}, struct { fn lessThan(_: void, a: *Page, b: *Page) bool { const iframe_a = a.iframe orelse return false; const iframe_b = b.iframe orelse return true; const pos = iframe_a.asNode().compareDocumentPosition(iframe_b.asNode()); // Return true if a precedes b (a should come before b in sorted order) return (pos & 0x04) != 0; // FOLLOWING bit: b follows a } }.lessThan); page.frames_sorted = true; } return frames[idx].window; } pub fn getFramesLength(self: *const Window) u32 { return @intCast(self._page.frames.items.len); } pub fn getScrollX(self: *const Window) u32 { return self._scroll_pos.x; } pub fn getScrollY(self: *const Window) u32 { return self._scroll_pos.y; } const ScrollToOpts = union(enum) { x: i32, opts: Opts, const Opts = struct { top: i32, left: i32, behavior: []const u8 = "", }; }; pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { switch (opts) { .x => |x| { self._scroll_pos.x = @intCast(@max(x, 0)); self._scroll_pos.y = @intCast(@max(0, y orelse 0)); }, .opts => |o| { self._scroll_pos.x = @intCast(@max(0, o.left)); self._scroll_pos.y = @intCast(@max(0, o.top)); }, } self._scroll_pos.state = .scroll; // We dispatch scroll event asynchronously after 10ms. So we can throttle // them. try page.js.scheduler.add( page, struct { fn dispatch(_page: *anyopaque) anyerror!?u32 { const p: *Page = @ptrCast(@alignCast(_page)); const pos = &p.window._scroll_pos; // If the state isn't scroll, we can ignore safely to throttle // the events. if (pos.state != .scroll) { return null; } const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p); try p._event_manager.dispatch(p.document.asEventTarget(), event); pos.state = .end; return null; } }.dispatch, 10, .{ .low_priority = true }, ); // We dispatch scrollend event asynchronously after 20ms. try page.js.scheduler.add( page, struct { fn dispatch(_page: *anyopaque) anyerror!?u32 { const p: *Page = @ptrCast(@alignCast(_page)); const pos = &p.window._scroll_pos; // Dispatch only if the state is .end. // If a scroll is pending, retry in 10ms. // If the state is .end, the event has been dispatched, so // ignore safely. switch (pos.state) { .scroll => return 10, .end => {}, .done => return null, } const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p); try p._event_manager.dispatch(p.document.asEventTarget(), event); pos.state = .done; return null; } }.dispatch, 20, .{ .low_priority = true }, ); } pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { // The scroll is relative to the current position. So compute to new // absolute position. var absx: i32 = undefined; var absy: i32 = undefined; switch (opts) { .x => |x| { absx = @as(i32, @intCast(self._scroll_pos.x)) + x; absy = @as(i32, @intCast(self._scroll_pos.y)) + (y orelse 0); }, .opts => |o| { absx = @as(i32, @intCast(self._scroll_pos.x)) + o.left; absy = @as(i32, @intCast(self._scroll_pos.y)) + o.top; }, } return self.scrollTo(.{ .x = absx }, absy, page); } pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.PromiseRejection, page: *Page) !void { if (comptime IS_DEBUG) { log.debug(.js, "unhandled rejection", .{ .value = rejection.reason(), .stack = rejection.local.stackTrace() catch |err| @errorName(err) orelse "???", }); } const event_name, const attribute_callback = blk: { if (no_handler) { break :blk .{ "unhandledrejection", self._on_unhandled_rejection }; } break :blk .{ "rejectionhandled", self._on_rejection_handled }; }; const target = self.asEventTarget(); if (page._event_manager.hasDirectListeners(target, event_name, attribute_callback)) { const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ .reason = if (rejection.reason()) |r| try r.temp() else null, .promise = try rejection.promise().temp(), }, page)).asEvent(); try page._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" }); } } const ScheduleOpts = struct { repeat: bool, params: []js.Value.Temp, name: []const u8, low_priority: bool = false, animation_frame: bool = false, mode: ScheduleCallback.Mode = .normal, }; fn scheduleCallback(self: *Window, cb: js.Function.Temp, delay_ms: u32, opts: ScheduleOpts, page: *Page) !u32 { if (self._timers.count() > 512) { // these are active return error.TooManyTimeout; } const arena = try page.getArena(.{ .debug = "Window.schedule" }); errdefer page.releaseArena(arena); const timer_id = self._timer_id +% 1; self._timer_id = timer_id; const params = opts.params; var persisted_params: []js.Value.Temp = &.{}; if (params.len > 0) { persisted_params = try arena.dupe(js.Value.Temp, params); } const gop = try self._timers.getOrPut(page.arena, timer_id); if (gop.found_existing) { // 2^31 would have to wrap for this to happen. return error.TooManyTimeout; } errdefer _ = self._timers.remove(timer_id); const callback = try arena.create(ScheduleCallback); callback.* = .{ .cb = cb, .page = page, .arena = arena, .mode = opts.mode, .name = opts.name, .timer_id = timer_id, .params = persisted_params, .repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null, }; gop.value_ptr.* = callback; try page.js.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{ .name = opts.name, .low_priority = opts.low_priority, .finalizer = ScheduleCallback.cancelled, }); return timer_id; } const ScheduleCallback = struct { // for debugging name: []const u8, // window._timers key timer_id: u31, // delay, in ms, to repeat. When null, will be removed after the first time repeat_ms: ?u32, cb: js.Function.Temp, mode: Mode, page: *Page, arena: Allocator, removed: bool = false, params: []const js.Value.Temp, const Mode = enum { idle, normal, animation_frame, }; fn cancelled(ctx: *anyopaque) void { var self: *ScheduleCallback = @ptrCast(@alignCast(ctx)); self.deinit(); } fn deinit(self: *ScheduleCallback) void { self.cb.release(); for (self.params) |param| { param.release(); } self.page.releaseArena(self.arena); } fn run(ctx: *anyopaque) !?u32 { const self: *ScheduleCallback = @ptrCast(@alignCast(ctx)); const page = self.page; const window = page.window; if (self.removed) { _ = window._timers.remove(self.timer_id); self.deinit(); return null; } var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); switch (self.mode) { .idle => { const IdleDeadline = @import("IdleDeadline.zig"); ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| { log.warn(.js, "window.idleCallback", .{ .name = self.name, .err = err }); }; }, .animation_frame => { ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| { log.warn(.js, "window.RAF", .{ .name = self.name, .err = err }); }; }, .normal => { ls.toLocal(self.cb).call(void, self.params) catch |err| { log.warn(.js, "window.timer", .{ .name = self.name, .err = err }); }; }, } ls.local.runMicrotasks(); if (self.repeat_ms) |ms| { return ms; } defer self.deinit(); _ = window._timers.remove(self.timer_id); return null; } }; const PostMessageCallback = struct { page: *Page, source: *Window, arena: Allocator, origin: []const u8, message: js.Value.Temp, fn deinit(self: *PostMessageCallback) void { self.page.releaseArena(self.arena); } fn cancelled(ctx: *anyopaque) void { const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); self.deinit(); } fn run(ctx: *anyopaque) !?u32 { const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); defer self.deinit(); const page = self.page; const window = page.window; const event_target = window.asEventTarget(); if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) { const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ .data = self.message, .origin = self.origin, .source = self.source, .bubbles = false, .cancelable = false, }, page)).asEvent(); try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" }); } return null; } }; const FunctionSetter = union(enum) { func: js.Function.Global, anything: js.Value, }; // window.onload = {}; doesn't fail, but it doesn't do anything. // seems like setting to null is ok (though, at least on Firefix, it preserves // the original value, which we could do, but why?) fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { const setter = setter_ orelse return null; return switch (setter) { .func => |func| func, // Already a Global from bridge auto-conversion .anything => null, }; } pub const JsApi = struct { pub const bridge = js.Bridge(Window); pub const Meta = struct { pub const name = "Window"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } }); pub const top = bridge.accessor(Window.getTop, null, .{}); pub const self = bridge.accessor(Window.getWindow, null, .{}); pub const window = bridge.accessor(Window.getWindow, null, .{}); pub const parent = bridge.accessor(Window.getParent, null, .{}); pub const navigator = bridge.accessor(Window.getNavigator, null, .{}); pub const screen = bridge.accessor(Window.getScreen, null, .{}); pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{}); pub const performance = bridge.accessor(Window.getPerformance, null, .{}); pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{}); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{}); pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{}); pub const history = bridge.accessor(Window.getHistory, null, .{}); pub const navigation = bridge.accessor(Window.getNavigation, null, .{}); pub const crypto = bridge.accessor(Window.getCrypto, null, .{}); pub const CSS = bridge.accessor(Window.getCSS, null, .{}); pub const customElements = bridge.accessor(Window.getCustomElements, null, .{}); pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{}); pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{}); pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{}); pub const onrejectionhandled = bridge.accessor(Window.getOnRejectionHandled, Window.setOnRejectionHandled, .{}); pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); pub const event = bridge.accessor(Window.getEvent, null, .{ .null_as_undefined = true }); pub const fetch = bridge.function(Window.fetch, .{}); pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{}); pub const setTimeout = bridge.function(Window.setTimeout, .{}); pub const clearTimeout = bridge.function(Window.clearTimeout, .{}); pub const setInterval = bridge.function(Window.setInterval, .{}); pub const clearInterval = bridge.function(Window.clearInterval, .{}); pub const setImmediate = bridge.function(Window.setImmediate, .{}); pub const clearImmediate = bridge.function(Window.clearImmediate, .{}); pub const requestAnimationFrame = bridge.function(Window.requestAnimationFrame, .{}); pub const cancelAnimationFrame = bridge.function(Window.cancelAnimationFrame, .{}); pub const requestIdleCallback = bridge.function(Window.requestIdleCallback, .{}); pub const cancelIdleCallback = bridge.function(Window.cancelIdleCallback, .{}); pub const matchMedia = bridge.function(Window.matchMedia, .{}); pub const postMessage = bridge.function(Window.postMessage, .{}); pub const btoa = bridge.function(Window.btoa, .{}); pub const atob = bridge.function(Window.atob, .{ .dom_exception = true }); pub const reportError = bridge.function(Window.reportError, .{}); pub const structuredClone = bridge.function(Window.structuredClone, .{}); pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{}); pub const getSelection = bridge.function(Window.getSelection, .{}); pub const frames = bridge.accessor(Window.getWindow, null, .{}); pub const index = bridge.indexed(Window.getFrame, null, .{ .null_as_undefined = true }); pub const length = bridge.accessor(Window.getFramesLength, null, .{}); pub const scrollX = bridge.accessor(Window.getScrollX, null, .{}); pub const scrollY = bridge.accessor(Window.getScrollY, null, .{}); pub const pageXOffset = bridge.accessor(Window.getScrollX, null, .{}); pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{}); pub const scrollTo = bridge.function(Window.scrollTo, .{}); pub const scroll = bridge.function(Window.scrollTo, .{}); pub const scrollBy = bridge.function(Window.scrollBy, .{}); // Return false since we don't have secure-context-only APIs implemented // (webcam, geolocation, clipboard, etc.) // This is safer and could help avoid processing errors by hinting at // sites not to try to access those features pub const isSecureContext = bridge.property(false, .{ .template = false }); pub const innerWidth = bridge.property(1920, .{ .template = false }); pub const innerHeight = bridge.property(1080, .{ .template = false }); pub const devicePixelRatio = bridge.property(1, .{ .template = false }); // This should return a window-like object in specific conditions. Would be // pretty complicated to properly support I think. pub const opener = bridge.property(null, .{ .template = false }); pub const alert = bridge.function(struct { fn alert(_: *const Window, _: ?[]const u8) void {} }.alert, .{ .noop = true }); pub const confirm = bridge.function(struct { fn confirm(_: *const Window, _: ?[]const u8) bool { return false; } }.confirm, .{}); pub const prompt = bridge.function(struct { fn prompt(_: *const Window, _: ?[]const u8, _: ?[]const u8) ?[]const u8 { return null; } }.prompt, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: Window" { try testing.htmlRunner("window", .{}); } test "WebApi: Window scroll" { try testing.htmlRunner("window_scroll.html", .{}); } test "WebApi: Window.onerror" { try testing.htmlRunner("event/report_error.html", .{}); } ================================================ FILE: src/browser/webapi/XMLDocument.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../js/js.zig"); const Document = @import("Document.zig"); const Node = @import("Node.zig"); const XMLDocument = @This(); _proto: *Document, pub fn asDocument(self: *XMLDocument) *Document { return self._proto; } pub fn asNode(self: *XMLDocument) *Node { return self._proto.asNode(); } pub fn asEventTarget(self: *XMLDocument) *@import("EventTarget.zig") { return self._proto.asEventTarget(); } pub const JsApi = struct { pub const bridge = js.Bridge(XMLDocument); pub const Meta = struct { pub const name = "XMLDocument"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/XMLSerializer.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const dump = @import("../dump.zig"); const XMLSerializer = @This(); // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, pub fn init() XMLSerializer { return .{}; } pub fn serializeToString(self: *const XMLSerializer, node: *Node, page: *Page) ![]const u8 { _ = self; var buf = std.Io.Writer.Allocating.init(page.call_arena); if (node.is(Node.Document)) |doc| { try dump.root(doc, .{ .shadow = .skip }, &buf.writer, page); } else { try dump.deep(node, .{ .shadow = .skip }, &buf.writer, page); } // Not sure about this trim. But `dump` is meant to display relatively // pretty HTML, so it does include newlines, which can result in a trailing // newline. XMLSerializer is a bit more strict. return std.mem.trim(u8, buf.written(), &std.ascii.whitespace); } pub const JsApi = struct { pub const bridge = js.Bridge(XMLSerializer); pub const Meta = struct { pub const name = "XMLSerializer"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const constructor = bridge.constructor(XMLSerializer.init, .{}); pub const serializeToString = bridge.function(XMLSerializer.serializeToString, .{}); }; const testing = @import("../../testing.zig"); test "WebApi: XMLSerializer" { try testing.htmlRunner("xmlserializer.html", .{}); } ================================================ FILE: src/browser/webapi/animation/Animation.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; const Animation = @This(); const PlayState = enum { idle, running, paused, finished, }; _page: *Page, _arena: Allocator, _effect: ?js.Object.Global = null, _timeline: ?js.Object.Global = null, _ready_resolver: ?js.PromiseResolver.Global = null, _finished_resolver: ?js.PromiseResolver.Global = null, _startTime: ?f64 = null, _onFinish: ?js.Function.Temp = null, _playState: PlayState = .idle, // Fake the animation by passing the states: // .idle => .running once play() is called. // .running => .finished after 10ms when update() is callback. // // TODO add support for effect and timeline pub fn init(page: *Page) !*Animation { const arena = try page.getArena(.{ .debug = "Animation" }); errdefer page.releaseArena(arena); const self = try arena.create(Animation); self.* = .{ ._page = page, ._arena = arena, }; return self; } pub fn deinit(self: *Animation, _: bool, session: *Session) void { session.releaseArena(self._arena); } pub fn play(self: *Animation, page: *Page) !void { if (self._playState == .running) { return; } // transition to running. self._playState = .running; // Schedule the transition from .running => .finished in 10ms. page.js.strongRef(self); try page.js.scheduler.add( self, Animation.update, 10, .{ .name = "animation.update" }, ); } pub fn pause(self: *Animation) void { self._playState = .paused; } pub fn cancel(self: *Animation) void { // Transition to idle. If the animation was .running, the already-scheduled // update() callback will fire but see .idle state, skip the finish // transition, and release the strong ref via weakRef() as normal. self._playState = .idle; } pub fn finish(self: *Animation, page: *Page) void { if (self._playState == .finished) { return; } self._playState = .finished; // resolve finished if (self._finished_resolver) |resolver| { page.js.local.?.toLocal(resolver).resolve("Animation.getFinished", self); } // call onfinish if (self._onFinish) |func| { page.js.local.?.toLocal(func).call(void, .{}) catch |err| { log.warn(.js, "Animation._onFinish", .{ .err = err }); }; } } pub fn reverse(_: *Animation) void { log.warn(.not_implemented, "Animation.reverse", .{}); } pub fn getFinished(self: *Animation, page: *Page) !js.Promise { if (self._finished_resolver == null) { const resolver = page.js.local.?.createPromiseResolver(); self._finished_resolver = try resolver.persist(); return resolver.promise(); } return page.js.toLocal(self._finished_resolver).?.promise(); } // The ready promise is immediately resolved. pub fn getReady(self: *Animation, page: *Page) !js.Promise { if (self._ready_resolver == null) { const resolver = page.js.local.?.createPromiseResolver(); resolver.resolve("Animation.getReady", self); self._ready_resolver = try resolver.persist(); return resolver.promise(); } return page.js.toLocal(self._ready_resolver).?.promise(); } pub fn getEffect(self: *const Animation) ?js.Object.Global { return self._effect; } pub fn setEffect(self: *Animation, effect: ?js.Object.Global) !void { self._effect = effect; } pub fn getTimeline(self: *const Animation) ?js.Object.Global { return self._timeline; } pub fn setTimeline(self: *Animation, timeline: ?js.Object.Global) !void { self._timeline = timeline; } pub fn getStartTime(self: *const Animation) ?f64 { return self._startTime; } pub fn setStartTime(self: *Animation, value: ?f64, page: *Page) !void { self._startTime = value; // if the startTime is null, don't play the animation. if (value == null) { return; } return self.play(page); } pub fn getOnFinish(self: *const Animation) ?js.Function.Temp { return self._onFinish; } // callback function transitionning from a state to another fn update(ctx: *anyopaque) !?u32 { const self: *Animation = @ptrCast(@alignCast(ctx)); switch (self._playState) { .running => { // transition to finished. self._playState = .finished; var ls: js.Local.Scope = undefined; self._page.js.localScope(&ls); defer ls.deinit(); // resolve finished if (self._finished_resolver) |resolver| { ls.toLocal(resolver).resolve("Animation.getFinished", self); } // call onfinish if (self._onFinish) |func| { ls.toLocal(func).call(void, .{}) catch |err| { log.warn(.js, "Animation._onFinish", .{ .err = err }); }; } }, .idle, .paused, .finished => {}, } // No future change scheduled, set the object weak for garbage collection. self._page.js.weakRef(self); return null; } pub fn setOnFinish(self: *Animation, cb: ?js.Function.Temp) !void { self._onFinish = cb; } pub fn playState(self: *const Animation) []const u8 { return @tagName(self._playState); } pub const JsApi = struct { pub const bridge = js.Bridge(Animation); pub const Meta = struct { pub const name = "Animation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(Animation.deinit); }; pub const play = bridge.function(Animation.play, .{}); pub const pause = bridge.function(Animation.pause, .{}); pub const cancel = bridge.function(Animation.cancel, .{}); pub const finish = bridge.function(Animation.finish, .{}); pub const reverse = bridge.function(Animation.reverse, .{}); pub const playState = bridge.accessor(Animation.playState, null, .{}); pub const pending = bridge.property(false, .{ .template = false }); pub const finished = bridge.accessor(Animation.getFinished, null, .{}); pub const ready = bridge.accessor(Animation.getReady, null, .{}); pub const effect = bridge.accessor(Animation.getEffect, Animation.setEffect, .{}); pub const timeline = bridge.accessor(Animation.getTimeline, Animation.setTimeline, .{}); pub const startTime = bridge.accessor(Animation.getStartTime, Animation.setStartTime, .{}); pub const onfinish = bridge.accessor(Animation.getOnFinish, Animation.setOnFinish, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: Animation" { try testing.htmlRunner("animation/animation.html", .{}); } ================================================ FILE: src/browser/webapi/canvas/CanvasRenderingContext2D.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const color = @import("../../color.zig"); const Page = @import("../../Page.zig"); const ImageData = @import("../ImageData.zig"); /// This class doesn't implement a `constructor`. /// It can be obtained with a call to `HTMLCanvasElement#getContext`. /// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D const CanvasRenderingContext2D = @This(); /// Fill color. /// TODO: Add support for `CanvasGradient` and `CanvasPattern`. _fill_style: color.RGBA = color.RGBA.Named.black, pub fn getFillStyle(self: *const CanvasRenderingContext2D, page: *Page) ![]const u8 { var w = std.Io.Writer.Allocating.init(page.call_arena); try self._fill_style.format(&w.writer); return w.written(); } pub fn setFillStyle( self: *CanvasRenderingContext2D, value: []const u8, ) !void { // Prefer the same fill_style if fails. self._fill_style = color.RGBA.parse(value) catch self._fill_style; } const WidthOrImageData = union(enum) { width: u32, image_data: *ImageData, }; pub fn createImageData( _: *const CanvasRenderingContext2D, width_or_image_data: WidthOrImageData, /// If `ImageData` variant preferred, this is null. maybe_height: ?u32, /// Can be used if width and height provided. maybe_settings: ?ImageData.ConstructorSettings, page: *Page, ) !*ImageData { switch (width_or_image_data) { .width => |width| { const height = maybe_height orelse return error.TypeError; return ImageData.init(width, height, maybe_settings, page); }, .image_data => |image_data| { return ImageData.init(image_data._width, image_data._height, null, page); }, } } pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} pub fn getImageData( _: *const CanvasRenderingContext2D, _: i32, // sx _: i32, // sy sw: i32, sh: i32, page: *Page, ) !*ImageData { if (sw <= 0 or sh <= 0) { return error.IndexSizeError; } return ImageData.init(@intCast(sw), @intCast(sh), null, page); } pub fn save(_: *CanvasRenderingContext2D) void {} pub fn restore(_: *CanvasRenderingContext2D) void {} pub fn scale(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} pub fn rotate(_: *CanvasRenderingContext2D, _: f64) void {} pub fn translate(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} pub fn transform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn setTransform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn resetTransform(_: *CanvasRenderingContext2D) void {} pub fn setStrokeStyle(_: *CanvasRenderingContext2D, _: []const u8) void {} pub fn clearRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn fillRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn strokeRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn beginPath(_: *CanvasRenderingContext2D) void {} pub fn closePath(_: *CanvasRenderingContext2D) void {} pub fn moveTo(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} pub fn lineTo(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} pub fn quadraticCurveTo(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn bezierCurveTo(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn arc(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: ?bool) void {} pub fn arcTo(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn rect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn fill(_: *CanvasRenderingContext2D) void {} pub fn stroke(_: *CanvasRenderingContext2D) void {} pub fn clip(_: *CanvasRenderingContext2D) void {} pub fn fillText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub fn strokeText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub const JsApi = struct { pub const bridge = js.Bridge(CanvasRenderingContext2D); pub const Meta = struct { pub const name = "CanvasRenderingContext2D"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false }); pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false }); pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false }); pub const strokeStyle = bridge.property("#000000", .{ .template = false, .readonly = false }); pub const lineWidth = bridge.property(1.0, .{ .template = false, .readonly = false }); pub const lineCap = bridge.property("butt", .{ .template = false, .readonly = false }); pub const lineJoin = bridge.property("miter", .{ .template = false, .readonly = false }); pub const miterLimit = bridge.property(10.0, .{ .template = false, .readonly = false }); pub const textAlign = bridge.property("start", .{ .template = false, .readonly = false }); pub const textBaseline = bridge.property("alphabetic", .{ .template = false, .readonly = false }); pub const fillStyle = bridge.accessor(CanvasRenderingContext2D.getFillStyle, CanvasRenderingContext2D.setFillStyle, .{}); pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{ .noop = true }); pub const getImageData = bridge.function(CanvasRenderingContext2D.getImageData, .{ .dom_exception = true }); pub const save = bridge.function(CanvasRenderingContext2D.save, .{ .noop = true }); pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{ .noop = true }); pub const scale = bridge.function(CanvasRenderingContext2D.scale, .{ .noop = true }); pub const rotate = bridge.function(CanvasRenderingContext2D.rotate, .{ .noop = true }); pub const translate = bridge.function(CanvasRenderingContext2D.translate, .{ .noop = true }); pub const transform = bridge.function(CanvasRenderingContext2D.transform, .{ .noop = true }); pub const setTransform = bridge.function(CanvasRenderingContext2D.setTransform, .{ .noop = true }); pub const resetTransform = bridge.function(CanvasRenderingContext2D.resetTransform, .{ .noop = true }); pub const clearRect = bridge.function(CanvasRenderingContext2D.clearRect, .{ .noop = true }); pub const fillRect = bridge.function(CanvasRenderingContext2D.fillRect, .{ .noop = true }); pub const strokeRect = bridge.function(CanvasRenderingContext2D.strokeRect, .{ .noop = true }); pub const beginPath = bridge.function(CanvasRenderingContext2D.beginPath, .{ .noop = true }); pub const closePath = bridge.function(CanvasRenderingContext2D.closePath, .{ .noop = true }); pub const moveTo = bridge.function(CanvasRenderingContext2D.moveTo, .{ .noop = true }); pub const lineTo = bridge.function(CanvasRenderingContext2D.lineTo, .{ .noop = true }); pub const quadraticCurveTo = bridge.function(CanvasRenderingContext2D.quadraticCurveTo, .{ .noop = true }); pub const bezierCurveTo = bridge.function(CanvasRenderingContext2D.bezierCurveTo, .{ .noop = true }); pub const arc = bridge.function(CanvasRenderingContext2D.arc, .{ .noop = true }); pub const arcTo = bridge.function(CanvasRenderingContext2D.arcTo, .{ .noop = true }); pub const rect = bridge.function(CanvasRenderingContext2D.rect, .{ .noop = true }); pub const fill = bridge.function(CanvasRenderingContext2D.fill, .{ .noop = true }); pub const stroke = bridge.function(CanvasRenderingContext2D.stroke, .{ .noop = true }); pub const clip = bridge.function(CanvasRenderingContext2D.clip, .{ .noop = true }); pub const fillText = bridge.function(CanvasRenderingContext2D.fillText, .{ .noop = true }); pub const strokeText = bridge.function(CanvasRenderingContext2D.strokeText, .{ .noop = true }); }; const testing = @import("../../../testing.zig"); test "WebApi: CanvasRenderingContext2D" { try testing.htmlRunner("canvas/canvas_rendering_context_2d.html", .{}); } ================================================ FILE: src/browser/webapi/canvas/OffscreenCanvas.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Blob = @import("../Blob.zig"); const OffscreenCanvasRenderingContext2D = @import("OffscreenCanvasRenderingContext2D.zig"); /// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas const OffscreenCanvas = @This(); pub const _prototype_root = true; _width: u32, _height: u32, /// Since there's no base class rendering contextes inherit from, /// we're using tagged union. const DrawingContext = union(enum) { @"2d": *OffscreenCanvasRenderingContext2D, }; pub fn constructor(width: u32, height: u32, page: *Page) !*OffscreenCanvas { return page._factory.create(OffscreenCanvas{ ._width = width, ._height = height, }); } pub fn getWidth(self: *const OffscreenCanvas) u32 { return self._width; } pub fn setWidth(self: *OffscreenCanvas, value: u32) void { self._width = value; } pub fn getHeight(self: *const OffscreenCanvas) u32 { return self._height; } pub fn setHeight(self: *OffscreenCanvas, value: u32) void { self._height = value; } pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, page: *Page) !?DrawingContext { if (std.mem.eql(u8, context_type, "2d")) { const ctx = try page._factory.create(OffscreenCanvasRenderingContext2D{}); return .{ .@"2d" = ctx }; } return null; } /// Returns a Promise that resolves to a Blob containing the image. /// Since we have no actual rendering, this returns an empty blob. pub fn convertToBlob(_: *OffscreenCanvas, page: *Page) !js.Promise { const blob = try Blob.init(null, null, page); return page.js.local.?.resolvePromise(blob); } /// Returns an ImageBitmap with the rendered content (stub). pub fn transferToImageBitmap(_: *OffscreenCanvas) ?void { // ImageBitmap not implemented yet, return null return null; } pub const JsApi = struct { pub const bridge = js.Bridge(OffscreenCanvas); pub const Meta = struct { pub const name = "OffscreenCanvas"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(OffscreenCanvas.constructor, .{}); pub const width = bridge.accessor(OffscreenCanvas.getWidth, OffscreenCanvas.setWidth, .{}); pub const height = bridge.accessor(OffscreenCanvas.getHeight, OffscreenCanvas.setHeight, .{}); pub const getContext = bridge.function(OffscreenCanvas.getContext, .{}); pub const convertToBlob = bridge.function(OffscreenCanvas.convertToBlob, .{}); pub const transferToImageBitmap = bridge.function(OffscreenCanvas.transferToImageBitmap, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: OffscreenCanvas" { try testing.htmlRunner("canvas/offscreen_canvas.html", .{}); } ================================================ FILE: src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const color = @import("../../color.zig"); const Page = @import("../../Page.zig"); const ImageData = @import("../ImageData.zig"); /// This class doesn't implement a `constructor`. /// It can be obtained with a call to `OffscreenCanvas#getContext`. /// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvasRenderingContext2D const OffscreenCanvasRenderingContext2D = @This(); /// Fill color. /// TODO: Add support for `CanvasGradient` and `CanvasPattern`. _fill_style: color.RGBA = color.RGBA.Named.black, pub fn getFillStyle(self: *const OffscreenCanvasRenderingContext2D, page: *Page) ![]const u8 { var w = std.Io.Writer.Allocating.init(page.call_arena); try self._fill_style.format(&w.writer); return w.written(); } pub fn setFillStyle( self: *OffscreenCanvasRenderingContext2D, value: []const u8, ) !void { // Prefer the same fill_style if fails. self._fill_style = color.RGBA.parse(value) catch self._fill_style; } const WidthOrImageData = union(enum) { width: u32, image_data: *ImageData, }; pub fn createImageData( _: *const OffscreenCanvasRenderingContext2D, width_or_image_data: WidthOrImageData, /// If `ImageData` variant preferred, this is null. maybe_height: ?u32, /// Can be used if width and height provided. maybe_settings: ?ImageData.ConstructorSettings, page: *Page, ) !*ImageData { switch (width_or_image_data) { .width => |width| { const height = maybe_height orelse return error.TypeError; return ImageData.init(width, height, maybe_settings, page); }, .image_data => |image_data| { return ImageData.init(image_data._width, image_data._height, null, page); }, } } pub fn putImageData(_: *const OffscreenCanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} pub fn getImageData( _: *const OffscreenCanvasRenderingContext2D, _: i32, // sx _: i32, // sy sw: i32, sh: i32, page: *Page, ) !*ImageData { if (sw <= 0 or sh <= 0) { return error.IndexSizeError; } return ImageData.init(@intCast(sw), @intCast(sh), null, page); } pub fn save(_: *OffscreenCanvasRenderingContext2D) void {} pub fn restore(_: *OffscreenCanvasRenderingContext2D) void {} pub fn scale(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} pub fn rotate(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} pub fn translate(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} pub fn transform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn setTransform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn resetTransform(_: *OffscreenCanvasRenderingContext2D) void {} pub fn setStrokeStyle(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} pub fn clearRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn fillRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn strokeRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn beginPath(_: *OffscreenCanvasRenderingContext2D) void {} pub fn closePath(_: *OffscreenCanvasRenderingContext2D) void {} pub fn moveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} pub fn lineTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} pub fn quadraticCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn bezierCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn arc(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: ?bool) void {} pub fn arcTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn rect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn fill(_: *OffscreenCanvasRenderingContext2D) void {} pub fn stroke(_: *OffscreenCanvasRenderingContext2D) void {} pub fn clip(_: *OffscreenCanvasRenderingContext2D) void {} pub fn fillText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub fn strokeText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub const JsApi = struct { pub const bridge = js.Bridge(OffscreenCanvasRenderingContext2D); pub const Meta = struct { pub const name = "OffscreenCanvasRenderingContext2D"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false }); pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false }); pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false }); pub const strokeStyle = bridge.property("#000000", .{ .template = false, .readonly = false }); pub const lineWidth = bridge.property(1.0, .{ .template = false, .readonly = false }); pub const lineCap = bridge.property("butt", .{ .template = false, .readonly = false }); pub const lineJoin = bridge.property("miter", .{ .template = false, .readonly = false }); pub const miterLimit = bridge.property(10.0, .{ .template = false, .readonly = false }); pub const textAlign = bridge.property("start", .{ .template = false, .readonly = false }); pub const textBaseline = bridge.property("alphabetic", .{ .template = false, .readonly = false }); pub const fillStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getFillStyle, OffscreenCanvasRenderingContext2D.setFillStyle, .{}); pub const createImageData = bridge.function(OffscreenCanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); pub const putImageData = bridge.function(OffscreenCanvasRenderingContext2D.putImageData, .{ .noop = true }); pub const getImageData = bridge.function(OffscreenCanvasRenderingContext2D.getImageData, .{ .dom_exception = true }); pub const save = bridge.function(OffscreenCanvasRenderingContext2D.save, .{ .noop = true }); pub const restore = bridge.function(OffscreenCanvasRenderingContext2D.restore, .{ .noop = true }); pub const scale = bridge.function(OffscreenCanvasRenderingContext2D.scale, .{ .noop = true }); pub const rotate = bridge.function(OffscreenCanvasRenderingContext2D.rotate, .{ .noop = true }); pub const translate = bridge.function(OffscreenCanvasRenderingContext2D.translate, .{ .noop = true }); pub const transform = bridge.function(OffscreenCanvasRenderingContext2D.transform, .{ .noop = true }); pub const setTransform = bridge.function(OffscreenCanvasRenderingContext2D.setTransform, .{ .noop = true }); pub const resetTransform = bridge.function(OffscreenCanvasRenderingContext2D.resetTransform, .{ .noop = true }); pub const clearRect = bridge.function(OffscreenCanvasRenderingContext2D.clearRect, .{ .noop = true }); pub const fillRect = bridge.function(OffscreenCanvasRenderingContext2D.fillRect, .{ .noop = true }); pub const strokeRect = bridge.function(OffscreenCanvasRenderingContext2D.strokeRect, .{ .noop = true }); pub const beginPath = bridge.function(OffscreenCanvasRenderingContext2D.beginPath, .{ .noop = true }); pub const closePath = bridge.function(OffscreenCanvasRenderingContext2D.closePath, .{ .noop = true }); pub const moveTo = bridge.function(OffscreenCanvasRenderingContext2D.moveTo, .{ .noop = true }); pub const lineTo = bridge.function(OffscreenCanvasRenderingContext2D.lineTo, .{ .noop = true }); pub const quadraticCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.quadraticCurveTo, .{ .noop = true }); pub const bezierCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.bezierCurveTo, .{ .noop = true }); pub const arc = bridge.function(OffscreenCanvasRenderingContext2D.arc, .{ .noop = true }); pub const arcTo = bridge.function(OffscreenCanvasRenderingContext2D.arcTo, .{ .noop = true }); pub const rect = bridge.function(OffscreenCanvasRenderingContext2D.rect, .{ .noop = true }); pub const fill = bridge.function(OffscreenCanvasRenderingContext2D.fill, .{ .noop = true }); pub const stroke = bridge.function(OffscreenCanvasRenderingContext2D.stroke, .{ .noop = true }); pub const clip = bridge.function(OffscreenCanvasRenderingContext2D.clip, .{ .noop = true }); pub const fillText = bridge.function(OffscreenCanvasRenderingContext2D.fillText, .{ .noop = true }); pub const strokeText = bridge.function(OffscreenCanvasRenderingContext2D.strokeText, .{ .noop = true }); }; ================================================ FILE: src/browser/webapi/canvas/WebGLRenderingContext.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); pub fn registerTypes() []const type { return &.{ WebGLRenderingContext, // Extension types should be runtime generated. We might want // to revisit this. Extension.Type.WEBGL_debug_renderer_info, Extension.Type.WEBGL_lose_context, }; } const WebGLRenderingContext = @This(); /// On Chrome and Safari, a call to `getSupportedExtensions` returns total of 39. /// The reference for it lists lesser number of extensions: /// https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Using_Extensions#extension_list pub const Extension = union(enum) { ANGLE_instanced_arrays: void, EXT_blend_minmax: void, EXT_clip_control: void, EXT_color_buffer_half_float: void, EXT_depth_clamp: void, EXT_disjoint_timer_query: void, EXT_float_blend: void, EXT_frag_depth: void, EXT_polygon_offset_clamp: void, EXT_shader_texture_lod: void, EXT_texture_compression_bptc: void, EXT_texture_compression_rgtc: void, EXT_texture_filter_anisotropic: void, EXT_texture_mirror_clamp_to_edge: void, EXT_sRGB: void, KHR_parallel_shader_compile: void, OES_element_index_uint: void, OES_fbo_render_mipmap: void, OES_standard_derivatives: void, OES_texture_float: void, OES_texture_float_linear: void, OES_texture_half_float: void, OES_texture_half_float_linear: void, OES_vertex_array_object: void, WEBGL_blend_func_extended: void, WEBGL_color_buffer_float: void, WEBGL_compressed_texture_astc: void, WEBGL_compressed_texture_etc: void, WEBGL_compressed_texture_etc1: void, WEBGL_compressed_texture_pvrtc: void, WEBGL_compressed_texture_s3tc: void, WEBGL_compressed_texture_s3tc_srgb: void, WEBGL_debug_renderer_info: *Type.WEBGL_debug_renderer_info, WEBGL_debug_shaders: void, WEBGL_depth_texture: void, WEBGL_draw_buffers: void, WEBGL_lose_context: *Type.WEBGL_lose_context, WEBGL_multi_draw: void, WEBGL_polygon_mode: void, /// Reified enum type from the fields of this union. const Kind = blk: { const info = @typeInfo(Extension).@"union"; const fields = info.fields; var items: [fields.len]std.builtin.Type.EnumField = undefined; for (fields, 0..) |field, i| { items[i] = .{ .name = field.name, .value = i }; } break :blk @Type(.{ .@"enum" = .{ .tag_type = std.math.IntFittingRange(0, if (fields.len == 0) 0 else fields.len - 1), .fields = &items, .decls = &.{}, .is_exhaustive = true, }, }); }; /// Returns the `Extension.Kind` by its name. fn find(name: []const u8) ?Kind { // Just to make you really sad, this function has to be case-insensitive. // So here we copy what's being done in `std.meta.stringToEnum` but replace // the comparison function. const kvs = comptime build_kvs: { const T = Extension.Kind; const EnumKV = struct { []const u8, T }; var kvs_array: [@typeInfo(T).@"enum".fields.len]EnumKV = undefined; for (@typeInfo(T).@"enum".fields, 0..) |enumField, i| { kvs_array[i] = .{ enumField.name, @field(T, enumField.name) }; } break :build_kvs kvs_array[0..]; }; const Map = std.StaticStringMapWithEql(Extension.Kind, std.static_string_map.eqlAsciiIgnoreCase); const map = Map.initComptime(kvs); return map.get(name); } /// Extension types. pub const Type = struct { pub const WEBGL_debug_renderer_info = struct { _: u8 = 0, pub const UNMASKED_VENDOR_WEBGL: u64 = 0x9245; pub const UNMASKED_RENDERER_WEBGL: u64 = 0x9246; pub const JsApi = struct { pub const bridge = js.Bridge(WEBGL_debug_renderer_info); pub const Meta = struct { pub const name = "WEBGL_debug_renderer_info"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const UNMASKED_VENDOR_WEBGL = bridge.property(WEBGL_debug_renderer_info.UNMASKED_VENDOR_WEBGL, .{ .template = false, .readonly = true }); pub const UNMASKED_RENDERER_WEBGL = bridge.property(WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL, .{ .template = false, .readonly = true }); }; }; pub const WEBGL_lose_context = struct { _: u8 = 0, pub fn loseContext(_: *const WEBGL_lose_context) void {} pub fn restoreContext(_: *const WEBGL_lose_context) void {} pub const JsApi = struct { pub const bridge = js.Bridge(WEBGL_lose_context); pub const Meta = struct { pub const name = "WEBGL_lose_context"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const loseContext = bridge.function(WEBGL_lose_context.loseContext, .{ .noop = true }); pub const restoreContext = bridge.function(WEBGL_lose_context.restoreContext, .{ .noop = true }); }; }; }; }; /// This actually takes "GLenum" which, in fact, is a fancy way to say number. /// Return value also depends on what's being passed as `pname`; we don't really /// support any though. pub fn getParameter(_: *const WebGLRenderingContext, pname: u32) []const u8 { _ = pname; return ""; } /// Enables a WebGL extension. pub fn getExtension(_: *const WebGLRenderingContext, name: []const u8, page: *Page) !?Extension { const tag = Extension.find(name) orelse return null; return switch (tag) { .WEBGL_debug_renderer_info => { const info = try page._factory.create(Extension.Type.WEBGL_debug_renderer_info{}); return .{ .WEBGL_debug_renderer_info = info }; }, .WEBGL_lose_context => { const ctx = try page._factory.create(Extension.Type.WEBGL_lose_context{}); return .{ .WEBGL_lose_context = ctx }; }, inline else => |comptime_enum| @unionInit(Extension, @tagName(comptime_enum), {}), }; } /// Returns a list of all the supported WebGL extensions. pub fn getSupportedExtensions(_: *const WebGLRenderingContext) []const []const u8 { return std.meta.fieldNames(Extension.Kind); } pub const JsApi = struct { pub const bridge = js.Bridge(WebGLRenderingContext); pub const Meta = struct { pub const name = "WebGLRenderingContext"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const getParameter = bridge.function(WebGLRenderingContext.getParameter, .{}); pub const getExtension = bridge.function(WebGLRenderingContext.getExtension, .{}); pub const getSupportedExtensions = bridge.function(WebGLRenderingContext.getSupportedExtensions, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: WebGLRenderingContext" { try testing.htmlRunner("canvas/webgl_rendering_context.html", .{}); } ================================================ FILE: src/browser/webapi/cdata/CDATASection.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Text = @import("Text.zig"); const CDATASection = @This(); _proto: *Text, pub const JsApi = struct { pub const bridge = js.Bridge(CDATASection); pub const Meta = struct { pub const name = "CDATASection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/cdata/Comment.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CData = @import("../CData.zig"); const Comment = @This(); _proto: *CData, pub fn init(str: ?js.NullableString, page: *Page) !*Comment { const node = try page.createComment(if (str) |s| s.value else ""); return node.as(Comment); } pub const JsApi = struct { pub const bridge = js.Bridge(Comment); pub const Meta = struct { pub const name = "Comment"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const constructor = bridge.constructor(Comment.init, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: CData.Text" { try testing.htmlRunner("cdata/comment.html", .{}); } ================================================ FILE: src/browser/webapi/cdata/ProcessingInstruction.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const CData = @import("../CData.zig"); const ProcessingInstruction = @This(); _proto: *CData, _target: []const u8, pub fn getTarget(self: *const ProcessingInstruction) []const u8 { return self._target; } pub const JsApi = struct { pub const bridge = js.Bridge(ProcessingInstruction); pub const Meta = struct { pub const name = "ProcessingInstruction"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const target = bridge.accessor(ProcessingInstruction.getTarget, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: ProcessingInstruction" { try testing.htmlRunner("processing_instruction.html", .{}); } ================================================ FILE: src/browser/webapi/cdata/Text.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CData = @import("../CData.zig"); const Text = @This(); _proto: *CData, pub fn init(str: ?js.NullableString, page: *Page) !*Text { const node = try page.createTextNode(if (str) |s| s.value else ""); return node.as(Text); } pub fn getWholeText(self: *Text) []const u8 { return self._proto._data.str(); } pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text { const data = self._proto._data.str(); const byte_offset = CData.utf16OffsetToUtf8(data, offset) catch return error.IndexSizeError; const new_data = data[byte_offset..]; const new_node = try page.createTextNode(new_data); const new_text = new_node.as(Text); const node = self._proto.asNode(); // Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e), // then truncate original node (step 8). if (node.parentNode()) |parent| { const next_sibling = node.nextSibling(); _ = try parent.insertBefore(new_node, next_sibling, page); // splitText-specific range updates (steps 7b-7e) if (parent.getChildIndex(node)) |node_index| { page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index); } } // Step 8: truncate original node via replaceData(offset, count, ""). // Use replaceData instead of setData so live range updates fire // (matters for detached text nodes where steps 7b-7e were skipped). const length = self._proto.getLength(); try self._proto.replaceData(offset, length - offset, "", page); return new_text; } pub const JsApi = struct { pub const bridge = js.Bridge(Text); pub const Meta = struct { pub const name = "Text"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const constructor = bridge.constructor(Text.init, .{}); pub const wholeText = bridge.accessor(Text.getWholeText, null, .{}); pub const splitText = bridge.function(Text.splitText, .{ .dom_exception = true }); }; ================================================ FILE: src/browser/webapi/children.zig ================================================ const std = @import("std"); const Node = @import("Node.zig"); const LinkedList = std.DoublyLinkedList; // Our node._chilren is of type ?*NodeList. The extra (extra) indirection is to // keep memory size down. // First, a lot of nodes have no children. For these nodes, `?*NodeList = null` // will take 8 bytes and require no allocations (because an optional pointer in // Zig uses the address 0 to represent null, rather than a separate field). // Second, a lot of nodes will have one child. For these nodes, we'll also only // use 8 bytes, because @sizeOf(NodeList) == 8. This is the reason the // list: *LinkedList is behind a pointer. pub const Children = union(enum) { one: *Node, list: *LinkedList, pub fn first(self: *const Children) *Node { return switch (self.*) { .one => |n| n, .list => |list| Node.linkToNode(list.first.?), }; } pub fn last(self: *const Children) *Node { return switch (self.*) { .one => |n| n, .list => |list| Node.linkToNode(list.last.?), }; } pub fn len(self: *const Children) u32 { return switch (self.*) { .one => 1, .list => |list| @intCast(list.len()), }; } }; ================================================ FILE: src/browser/webapi/collections/ChildNodes.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const GenericIterator = @import("iterator.zig").Entry; // Optimized for node.childNodes, which has to be a live list. // No need to go through a TreeWalker or add any filtering. const ChildNodes = @This(); _arena: std.mem.Allocator, _last_index: usize, _last_length: ?u32, _last_node: ?*std.DoublyLinkedList.Node, _cached_version: usize, _node: *Node, pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); pub const EntryIterator = GenericIterator(Iterator, null); pub fn init(node: *Node, page: *Page) !*ChildNodes { const arena = try page.getArena(.{ .debug = "ChildNodes" }); errdefer page.releaseArena(arena); const self = try arena.create(ChildNodes); self.* = .{ ._node = node, ._arena = arena, ._last_index = 0, ._last_node = null, ._last_length = null, ._cached_version = page.version, }; return self; } pub fn deinit(self: *const ChildNodes, session: *Session) void { session.releaseArena(self._arena); } pub fn length(self: *ChildNodes, page: *Page) !u32 { if (self.versionCheck(page)) { if (self._last_length) |cached_length| { return cached_length; } } const children = self._node._children orelse return 0; // O(N) const len = children.len(); self._last_length = len; return len; } pub fn getAtIndex(self: *ChildNodes, index: usize, page: *Page) !?*Node { _ = self.versionCheck(page); var current = self._last_index; var node: ?*std.DoublyLinkedList.Node = null; if (index < current) { current = 0; node = self.first() orelse return null; } else { node = self._last_node orelse self.first() orelse return null; } defer self._last_index = current; while (node) |n| { if (index == current) { self._last_node = n; return Node.linkToNode(n); } current += 1; node = n.next; } self._last_node = null; return null; } pub fn first(self: *const ChildNodes) ?*std.DoublyLinkedList.Node { return &(self._node._children orelse return null).first()._child_link; } pub fn keys(self: *ChildNodes, page: *Page) !*KeyIterator { return .init(.{ .list = self }, page); } pub fn values(self: *ChildNodes, page: *Page) !*ValueIterator { return .init(.{ .list = self }, page); } pub fn entries(self: *ChildNodes, page: *Page) !*EntryIterator { return .init(.{ .list = self }, page); } fn versionCheck(self: *ChildNodes, page: *Page) bool { const current = page.version; if (current == self._cached_version) { return true; } self._last_index = 0; self._last_node = null; self._last_length = null; self._cached_version = current; return false; } const NodeList = @import("NodeList.zig"); pub fn runtimeGenericWrap(self: *ChildNodes, page: *Page) !*NodeList { return page._factory.create(NodeList{ ._data = .{ .child_nodes = self } }); } const Iterator = struct { index: u32 = 0, list: *ChildNodes, const Entry = struct { u32, *Node }; pub fn next(self: *Iterator, page: *Page) !?Entry { const index = self.index; const node = try self.list.getAtIndex(index, page) orelse return null; self.index = index + 1; return .{ index, node }; } }; ================================================ FILE: src/browser/webapi/collections/DOMTokenList.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const GenericIterator = @import("iterator.zig").Entry; pub const DOMTokenList = @This(); // There are a lot of inefficiencies in this code because the list is meant to // be live, e.g. reflect changes to the underlying attribute. The only good news // is that lists tend to be very short (often just 1 item). _element: *Element, _attribute_name: String, pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); pub const EntryIterator = GenericIterator(Iterator, null); const Lookup = std.StringArrayHashMapUnmanaged(void); const WHITESPACE = " \t\n\r\x0C"; pub fn length(self: *const DOMTokenList, page: *Page) !u32 { const tokens = try self.getTokens(page); return @intCast(tokens.count()); } // TODO: soooo..inefficient pub fn item(self: *const DOMTokenList, index: usize, page: *Page) !?[]const u8 { var i: usize = 0; const allocator = page.call_arena; var seen: std.StringArrayHashMapUnmanaged(void) = .empty; var it = std.mem.tokenizeAny(u8, self.getValue(), WHITESPACE); while (it.next()) |token| { const gop = try seen.getOrPut(allocator, token); if (!gop.found_existing) { if (i == index) { return token; } i += 1; } } return null; } pub fn contains(self: *const DOMTokenList, search: []const u8) !bool { var it = std.mem.tokenizeAny(u8, self.getValue(), WHITESPACE); while (it.next()) |token| { if (std.mem.eql(u8, search, token)) { return true; } } return false; } pub fn add(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !void { for (tokens) |token| { try validateToken(token); } var lookup = try self.getTokens(page); const allocator = page.call_arena; try lookup.ensureUnusedCapacity(allocator, tokens.len); for (tokens) |token| { try lookup.put(allocator, token, {}); } try self.updateAttribute(lookup, page); } pub fn remove(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !void { for (tokens) |token| { try validateToken(token); } var lookup = try self.getTokens(page); for (tokens) |token| { _ = lookup.orderedRemove(token); } try self.updateAttribute(lookup, page); } pub fn toggle(self: *DOMTokenList, token: []const u8, force: ?bool, page: *Page) !bool { try validateToken(token); const has_token = try self.contains(token); if (force) |f| { if (f) { if (!has_token) { const tokens_to_add = [_][]const u8{token}; try self.add(&tokens_to_add, page); } return true; } else { if (has_token) { const tokens_to_remove = [_][]const u8{token}; try self.remove(&tokens_to_remove, page); } return false; } } else { if (has_token) { const tokens_to_remove = [_][]const u8{token}; try self.remove(tokens_to_remove[0..], page); return false; } else { const tokens_to_add = [_][]const u8{token}; try self.add(tokens_to_add[0..], page); return true; } } } pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8, page: *Page) !bool { // Validate in spec order: both empty first, then both whitespace if (old_token.len == 0 or new_token.len == 0) { return error.SyntaxError; } if (std.mem.indexOfAny(u8, old_token, WHITESPACE) != null) { return error.InvalidCharacterError; } if (std.mem.indexOfAny(u8, new_token, WHITESPACE) != null) { return error.InvalidCharacterError; } var lookup = try self.getTokens(page); // Check if old_token exists if (!lookup.contains(old_token)) { return false; } // If replacing with the same token, still need to trigger mutation if (std.mem.eql(u8, new_token, old_token)) { try self.updateAttribute(lookup, page); return true; } const allocator = page.call_arena; // Build new token list preserving order but replacing old with new var new_tokens = try std.ArrayList([]const u8).initCapacity(allocator, lookup.count()); var replaced_old = false; for (lookup.keys()) |token| { if (std.mem.eql(u8, token, old_token) and !replaced_old) { new_tokens.appendAssumeCapacity(new_token); replaced_old = true; } else if (std.mem.eql(u8, token, old_token)) { // Subsequent occurrences of old_token: skip (remove duplicates) continue; } else if (std.mem.eql(u8, token, new_token) and replaced_old) { // Occurrence of new_token AFTER replacement: skip (remove duplicate) continue; } else { // Any other token (including new_token before replacement): keep it new_tokens.appendAssumeCapacity(token); } } // Rebuild lookup var new_lookup: Lookup = .empty; try new_lookup.ensureTotalCapacity(allocator, new_tokens.items.len); for (new_tokens.items) |token| { try new_lookup.put(allocator, token, {}); } try self.updateAttribute(new_lookup, page); return true; } pub fn getValue(self: *const DOMTokenList) []const u8 { return self._element.getAttributeSafe(self._attribute_name) orelse ""; } pub fn setValue(self: *DOMTokenList, value: String, page: *Page) !void { try self._element.setAttribute(self._attribute_name, value, page); } pub fn keys(self: *DOMTokenList, page: *Page) !*KeyIterator { return .init(.{ .list = self }, page); } pub fn values(self: *DOMTokenList, page: *Page) !*ValueIterator { return .init(.{ .list = self }, page); } pub fn entries(self: *DOMTokenList, page: *Page) !*EntryIterator { return .init(.{ .list = self }, page); } pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, page: *Page) !void { const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_; const allocator = page.call_arena; var i: i32 = 0; var seen: std.StringArrayHashMapUnmanaged(void) = .empty; var it = std.mem.tokenizeAny(u8, self.getValue(), WHITESPACE); while (it.next()) |token| { const gop = try seen.getOrPut(allocator, token); if (gop.found_existing) { continue; } var caught: js.TryCatch.Caught = undefined; cb.tryCall(void, .{ token, i, self }, &caught) catch { log.debug(.js, "forEach callback", .{ .caught = caught, .source = "DOMTokenList" }); return; }; i += 1; } } fn getTokens(self: *const DOMTokenList, page: *Page) !Lookup { const value = self.getValue(); if (value.len == 0) { return .empty; } var list: Lookup = .empty; const allocator = page.call_arena; try list.ensureTotalCapacity(allocator, 4); var it = std.mem.tokenizeAny(u8, value, WHITESPACE); while (it.next()) |token| { try list.put(allocator, token, {}); } return list; } fn validateToken(token: []const u8) !void { if (token.len == 0) { return error.SyntaxError; } if (std.mem.indexOfAny(u8, token, &std.ascii.whitespace) != null) { return error.InvalidCharacterError; } } fn updateAttribute(self: *DOMTokenList, tokens: Lookup, page: *Page) !void { if (tokens.count() > 0) { const joined = try std.mem.join(page.call_arena, " ", tokens.keys()); return self._element.setAttribute(self._attribute_name, .wrap(joined), page); } // Only remove attribute if it didn't exist before (was null) // If it existed (even as ""), set it to "" to preserve its existence if (self._element.hasAttributeSafe(self._attribute_name)) { try self._element.setAttribute(self._attribute_name, .wrap(""), page); } } const Iterator = struct { index: u32 = 0, list: *DOMTokenList, const Entry = struct { u32, []const u8 }; pub fn next(self: *Iterator, page: *Page) !?Entry { const index = self.index; const node = try self.list.item(index, page) orelse return null; self.index = index + 1; return .{ index, node }; } }; pub const JsApi = struct { pub const bridge = js.Bridge(DOMTokenList); pub const Meta = struct { pub const name = "DOMTokenList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const length = bridge.accessor(DOMTokenList.length, null, .{}); pub const item = bridge.function(_item, .{}); fn _item(self: *const DOMTokenList, index: i32, page: *Page) !?[]const u8 { if (index < 0) { return null; } return self.item(@intCast(index), page); } pub const contains = bridge.function(DOMTokenList.contains, .{ .dom_exception = true }); pub const add = bridge.function(DOMTokenList.add, .{ .dom_exception = true }); pub const remove = bridge.function(DOMTokenList.remove, .{ .dom_exception = true }); pub const toggle = bridge.function(DOMTokenList.toggle, .{ .dom_exception = true }); pub const replace = bridge.function(DOMTokenList.replace, .{ .dom_exception = true }); pub const value = bridge.accessor(DOMTokenList.getValue, DOMTokenList.setValue, .{}); pub const toString = bridge.function(DOMTokenList.getValue, .{}); pub const keys = bridge.function(DOMTokenList.keys, .{}); pub const values = bridge.function(DOMTokenList.values, .{}); pub const entries = bridge.function(DOMTokenList.entries, .{}); pub const symbol_iterator = bridge.iterator(DOMTokenList.values, .{}); pub const forEach = bridge.function(DOMTokenList.forEach, .{}); pub const @"[]" = bridge.indexed(DOMTokenList.item, null, .{ .null_as_undefined = true }); }; ================================================ FILE: src/browser/webapi/collections/HTMLAllCollection.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const HTMLAllCollection = @This(); _tw: TreeWalker.FullExcludeSelf, _last_index: usize, _last_length: ?u32, _cached_version: usize, pub fn init(root: *Node, page: *Page) HTMLAllCollection { return .{ ._last_index = 0, ._last_length = null, ._tw = TreeWalker.FullExcludeSelf.init(root, .{}), ._cached_version = page.version, }; } fn versionCheck(self: *HTMLAllCollection, page: *const Page) bool { if (self._cached_version != page.version) { self._cached_version = page.version; self._last_index = 0; self._last_length = null; self._tw.reset(); return false; } return true; } pub fn length(self: *HTMLAllCollection, page: *const Page) u32 { if (self.versionCheck(page)) { if (self._last_length) |cached_length| { return cached_length; } } lp.assert(self._last_index == 0, "HTMLAllCollection.length", .{ .last_index = self._last_index }); var tw = &self._tw; defer tw.reset(); var l: u32 = 0; while (tw.next()) |node| { if (node.is(Element) != null) { l += 1; } } self._last_length = l; return l; } pub fn getAtIndex(self: *HTMLAllCollection, index: usize, page: *const Page) ?*Element { _ = self.versionCheck(page); var current = self._last_index; if (index <= current) { current = 0; self._tw.reset(); } defer self._last_index = current + 1; const tw = &self._tw; while (tw.next()) |node| { if (node.is(Element)) |el| { if (index == current) { return el; } current += 1; } } return null; } pub fn getByName(self: *HTMLAllCollection, name: []const u8, page: *Page) ?*Element { // First, try fast ID lookup using the document's element map if (page.document._elements_by_id.get(name)) |el| { return el; } // Fall back to searching by name attribute // Clone the tree walker to preserve _last_index optimization _ = self.versionCheck(page); var tw = self._tw.clone(); tw.reset(); while (tw.next()) |node| { if (node.is(Element)) |el| { if (el.getAttributeSafe(comptime .wrap("name"))) |attr_name| { if (std.mem.eql(u8, attr_name, name)) { return el; } } } } return null; } const CAllAsFunctionArg = union(enum) { index: u32, id: []const u8, }; pub fn callable(self: *HTMLAllCollection, arg: CAllAsFunctionArg, page: *Page) ?*Element { return switch (arg) { .index => |i| self.getAtIndex(i, page), .id => |id| self.getByName(id, page), }; } pub fn iterator(self: *HTMLAllCollection, page: *Page) !*Iterator { return Iterator.init(.{ .list = self, .tw = self._tw.clone(), }, page); } const GenericIterator = @import("iterator.zig").Entry; pub const Iterator = GenericIterator(struct { list: *HTMLAllCollection, tw: TreeWalker.FullExcludeSelf, pub fn next(self: *@This(), _: *Page) ?*Element { while (self.tw.next()) |node| { if (node.is(Element)) |el| { return el; } } return null; } }, null); pub const JsApi = struct { pub const bridge = js.Bridge(HTMLAllCollection); pub const Meta = struct { pub const name = "HTMLAllCollection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; // This is a very weird class that requires special JavaScript behavior // this htmldda and callable are only used here.. pub const htmldda = true; pub const callable = JsApi.callable; }; pub const length = bridge.accessor(HTMLAllCollection.length, null, .{}); pub const @"[int]" = bridge.indexed(HTMLAllCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLAllCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); fn _item(self: *HTMLAllCollection, index: i32, page: *Page) ?*Element { if (index < 0) { return null; } return self.getAtIndex(@intCast(index), page); } pub const namedItem = bridge.function(HTMLAllCollection.getByName, .{}); pub const symbol_iterator = bridge.iterator(HTMLAllCollection.iterator, .{}); pub const callable = bridge.callable(HTMLAllCollection.callable, .{ .null_as_undefined = true }); }; ================================================ FILE: src/browser/webapi/collections/HTMLCollection.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const NodeLive = @import("node_live.zig").NodeLive; const Mode = enum { tag, tag_name, tag_name_ns, class_name, all_elements, child_elements, child_tag, selected_options, links, anchors, form, empty, }; const HTMLCollection = @This(); _data: union(Mode) { tag: NodeLive(.tag), tag_name: NodeLive(.tag_name), tag_name_ns: NodeLive(.tag_name_ns), class_name: NodeLive(.class_name), all_elements: NodeLive(.all_elements), child_elements: NodeLive(.child_elements), child_tag: NodeLive(.child_tag), selected_options: NodeLive(.selected_options), links: NodeLive(.links), anchors: NodeLive(.anchors), form: NodeLive(.form), empty: void, }, pub fn length(self: *HTMLCollection, page: *const Page) u32 { return switch (self._data) { .empty => 0, inline else => |*impl| impl.length(page), }; } pub fn getAtIndex(self: *HTMLCollection, index: usize, page: *const Page) ?*Element { return switch (self._data) { .empty => null, inline else => |*impl| impl.getAtIndex(index, page), }; } pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element { return switch (self._data) { .empty => null, inline else => |*impl| impl.getByName(name, page), }; } pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { return Iterator.init(.{ .list = self, .tw = switch (self._data) { .tag => |*impl| .{ .tag = impl._tw.clone() }, .tag_name => |*impl| .{ .tag_name = impl._tw.clone() }, .tag_name_ns => |*impl| .{ .tag_name_ns = impl._tw.clone() }, .class_name => |*impl| .{ .class_name = impl._tw.clone() }, .all_elements => |*impl| .{ .all_elements = impl._tw.clone() }, .child_elements => |*impl| .{ .child_elements = impl._tw.clone() }, .child_tag => |*impl| .{ .child_tag = impl._tw.clone() }, .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, .links => |*impl| .{ .links = impl._tw.clone() }, .anchors => |*impl| .{ .anchors = impl._tw.clone() }, .form => |*impl| .{ .form = impl._tw.clone() }, .empty => .empty, }, }, page); } const GenericIterator = @import("iterator.zig").Entry; pub const Iterator = GenericIterator(struct { list: *HTMLCollection, tw: union(Mode) { tag: TreeWalker.FullExcludeSelf, tag_name: TreeWalker.FullExcludeSelf, tag_name_ns: TreeWalker.FullExcludeSelf, class_name: TreeWalker.FullExcludeSelf, all_elements: TreeWalker.FullExcludeSelf, child_elements: TreeWalker.Children, child_tag: TreeWalker.Children, selected_options: TreeWalker.Children, links: TreeWalker.FullExcludeSelf, anchors: TreeWalker.FullExcludeSelf, form: TreeWalker.FullExcludeSelf, empty: void, }, pub fn next(self: *@This(), _: *Page) ?*Element { return switch (self.list._data) { .tag => |*impl| impl.nextTw(&self.tw.tag), .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), .tag_name_ns => |*impl| impl.nextTw(&self.tw.tag_name_ns), .class_name => |*impl| impl.nextTw(&self.tw.class_name), .all_elements => |*impl| impl.nextTw(&self.tw.all_elements), .child_elements => |*impl| impl.nextTw(&self.tw.child_elements), .child_tag => |*impl| impl.nextTw(&self.tw.child_tag), .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), .links => |*impl| impl.nextTw(&self.tw.links), .anchors => |*impl| impl.nextTw(&self.tw.anchors), .form => |*impl| impl.nextTw(&self.tw.form), .empty => return null, }; } }, null); pub const JsApi = struct { pub const bridge = js.Bridge(HTMLCollection); pub const Meta = struct { pub const name = "HTMLCollection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const length = bridge.accessor(HTMLCollection.length, null, .{}); pub const @"[int]" = bridge.indexed(HTMLCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); fn _item(self: *HTMLCollection, index: i32, page: *Page) ?*Element { if (index < 0) { return null; } return self.getAtIndex(@intCast(index), page); } pub const namedItem = bridge.function(HTMLCollection.getByName, .{}); pub const symbol_iterator = bridge.iterator(HTMLCollection.iterator, .{}); }; ================================================ FILE: src/browser/webapi/collections/HTMLFormControlsCollection.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const NodeList = @import("NodeList.zig"); const RadioNodeList = @import("RadioNodeList.zig"); const HTMLCollection = @import("HTMLCollection.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const HTMLFormControlsCollection = @This(); _proto: *HTMLCollection, pub const NamedItemResult = union(enum) { element: *Element, radio_node_list: *RadioNodeList, }; pub fn length(self: *HTMLFormControlsCollection, page: *Page) u32 { return self._proto.length(page); } pub fn getAtIndex(self: *HTMLFormControlsCollection, index: usize, page: *Page) ?*Element { return self._proto.getAtIndex(index, page); } pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, page: *Page) !?NamedItemResult { if (name.len == 0) { return null; } // We need special handling for radio, where multiple inputs can have the // same name, but we also need to handle the [incorrect] case where non- // radios share names. var count: u32 = 0; var first_element: ?*Element = null; var it = try self.iterator(); while (it.next()) |element| { const is_match = blk: { if (element.getAttributeSafe(comptime .wrap("id"))) |id| { if (std.mem.eql(u8, id, name)) { break :blk true; } } if (element.getAttributeSafe(comptime .wrap("name"))) |elem_name| { if (std.mem.eql(u8, elem_name, name)) { break :blk true; } } break :blk false; }; if (is_match) { if (first_element == null) { first_element = element; } count += 1; if (count == 2) { const radio_node_list = try page._factory.create(RadioNodeList{ ._proto = undefined, ._form_collection = self, ._name = try page.dupeString(name), }); radio_node_list._proto = try page._factory.create(NodeList{ ._data = .{ .radio_node_list = radio_node_list } }); return .{ .radio_node_list = radio_node_list }; } } } if (count == 0) { return null; } // case == 2 was handled inside the loop if (comptime IS_DEBUG) { std.debug.assert(count == 1); } return .{ .element = first_element.? }; } // used internally, by HTMLFormControlsCollection and RadioNodeList pub fn iterator(self: *HTMLFormControlsCollection) !Iterator { const form_collection = self._proto._data.form; return .{ .tw = form_collection._tw.clone(), .nodes = form_collection, }; } // Used internally. Presents a nicer (more zig-like) iterator and strips away // some of the abstraction. pub const Iterator = struct { tw: TreeWalker, nodes: NodeLive, const NodeLive = @import("node_live.zig").NodeLive(.form); const TreeWalker = @import("../TreeWalker.zig").FullExcludeSelf; pub fn next(self: *Iterator) ?*Element { return self.nodes.nextTw(&self.tw); } }; pub const JsApi = struct { pub const bridge = js.Bridge(HTMLFormControlsCollection); pub const Meta = struct { pub const name = "HTMLFormControlsCollection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const manage = false; }; pub const length = bridge.accessor(HTMLFormControlsCollection.length, null, .{}); pub const @"[int]" = bridge.indexed(HTMLFormControlsCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLFormControlsCollection.namedItem, null, null, .{ .null_as_undefined = true }); pub const namedItem = bridge.function(HTMLFormControlsCollection.namedItem, .{}); }; ================================================ FILE: src/browser/webapi/collections/HTMLOptionsCollection.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const HTMLCollection = @import("HTMLCollection.zig"); const HTMLOptionsCollection = @This(); _proto: *HTMLCollection, _select: *@import("../element/html/Select.zig"), // Forward length to HTMLCollection pub fn length(self: *HTMLOptionsCollection, page: *Page) u32 { return self._proto.length(page); } // Forward indexed access to HTMLCollection pub fn getAtIndex(self: *HTMLOptionsCollection, index: usize, page: *Page) ?*Element { return self._proto.getAtIndex(index, page); } pub fn getByName(self: *HTMLOptionsCollection, name: []const u8, page: *Page) ?*Element { return self._proto.getByName(name, page); } // Forward selectedIndex to the owning select element pub fn getSelectedIndex(self: *const HTMLOptionsCollection) i32 { return self._select.getSelectedIndex(); } pub fn setSelectedIndex(self: *HTMLOptionsCollection, index: i32) !void { return self._select.setSelectedIndex(index); } const Option = @import("../element/html/Option.zig"); const AddBeforeOption = union(enum) { option: *Option, index: u32, }; // Add a new option element pub fn add(self: *HTMLOptionsCollection, element: *Option, before_: ?AddBeforeOption, page: *Page) !void { const select_node = self._select.asNode(); const element_node = element.asElement().asNode(); var before_node: ?*Node = null; if (before_) |before| { switch (before) { .index => |idx| { if (self.getAtIndex(idx, page)) |el| { before_node = el.asNode(); } }, .option => |before_option| before_node = before_option.asNode(), } } _ = try select_node.insertBefore(element_node, before_node, page); } // Remove an option element by index pub fn remove(self: *HTMLOptionsCollection, index: i32, page: *Page) void { if (index < 0) { return; } if (self._proto.getAtIndex(@intCast(index), page)) |element| { element.remove(page); } } pub const JsApi = struct { pub const bridge = js.Bridge(HTMLOptionsCollection); pub const Meta = struct { pub const name = "HTMLOptionsCollection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const manage = false; }; pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{}); // Indexed access pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const selectedIndex = bridge.accessor(HTMLOptionsCollection.getSelectedIndex, HTMLOptionsCollection.setSelectedIndex, .{}); pub const add = bridge.function(HTMLOptionsCollection.add, .{}); pub const remove = bridge.function(HTMLOptionsCollection.remove, .{}); }; ================================================ FILE: src/browser/webapi/collections/NodeList.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const ChildNodes = @import("ChildNodes.zig"); const RadioNodeList = @import("RadioNodeList.zig"); const SelectorList = @import("../selector/List.zig"); const NodeLive = @import("node_live.zig").NodeLive; const NodeList = @This(); _data: union(enum) { child_nodes: *ChildNodes, selector_list: *SelectorList, radio_node_list: *RadioNodeList, name: NodeLive(.name), }, _rc: usize = 0, pub fn deinit(self: *NodeList, _: bool, session: *Session) void { const rc = self._rc; if (rc > 1) { self._rc = rc - 1; return; } switch (self._data) { .selector_list => |list| list.deinit(session), .child_nodes => |cn| cn.deinit(session), else => {}, } } pub fn acquireRef(self: *NodeList) void { self._rc += 1; } pub fn length(self: *NodeList, page: *Page) !u32 { return switch (self._data) { .child_nodes => |impl| impl.length(page), .selector_list => |impl| @intCast(impl.getLength()), .radio_node_list => |impl| impl.getLength(), .name => |*impl| impl.length(page), }; } pub fn indexedGet(self: *NodeList, index: usize, page: *Page) !*Node { return try self.getAtIndex(index, page) orelse return error.NotHandled; } pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node { return switch (self._data) { .child_nodes => |impl| impl.getAtIndex(index, page), .selector_list => |impl| impl.getAtIndex(index), .radio_node_list => |impl| impl.getAtIndex(index, page), .name => |*impl| if (impl.getAtIndex(index, page)) |el| el.asNode() else null, }; } pub fn keys(self: *NodeList, page: *Page) !*KeyIterator { return .init(.{ .list = self }, page); } pub fn values(self: *NodeList, page: *Page) !*ValueIterator { return .init(.{ .list = self }, page); } pub fn entries(self: *NodeList, page: *Page) !*EntryIterator { return .init(.{ .list = self }, page); } pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { var i: i32 = 0; var it = try self.values(page); while (true) : (i += 1) { const next = try it.next(page); if (next.done) { return; } var caught: js.TryCatch.Caught = undefined; cb.tryCall(void, .{ next.value, i, self }, &caught) catch { log.debug(.js, "forEach callback", .{ .caught = caught, .source = "nodelist" }); return; }; } } const GenericIterator = @import("iterator.zig").Entry; pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); pub const EntryIterator = GenericIterator(Iterator, null); const Iterator = struct { index: u32 = 0, list: *NodeList, const Entry = struct { u32, *Node }; pub fn deinit(self: *Iterator, shutdown: bool, session: *Session) void { self.list.deinit(shutdown, session); } pub fn acquireRef(self: *Iterator) void { self.list.acquireRef(); } pub fn next(self: *Iterator, page: *Page) !?Entry { const index = self.index; const node = try self.list.getAtIndex(index, page) orelse return null; self.index = index + 1; return .{ index, node }; } }; pub const JsApi = struct { pub const bridge = js.Bridge(NodeList); pub const Meta = struct { pub const name = "NodeList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; pub const weak = true; pub const finalizer = bridge.finalizer(NodeList.deinit); }; pub const length = bridge.accessor(NodeList.length, null, .{}); pub const @"[]" = bridge.indexed(NodeList.indexedGet, getIndexes, .{ .null_as_undefined = true }); pub const item = bridge.function(NodeList.getAtIndex, .{}); pub const keys = bridge.function(NodeList.keys, .{}); pub const values = bridge.function(NodeList.values, .{}); pub const entries = bridge.function(NodeList.entries, .{}); pub const forEach = bridge.function(NodeList.forEach, .{}); pub const symbol_iterator = bridge.iterator(NodeList.values, .{}); fn getIndexes(self: *NodeList, page: *Page) !js.Array { const len = try self.length(page); var arr = page.js.local.?.newArray(len); for (0..len) |i| { _ = try arr.set(@intCast(i), i, .{}); } return arr; } }; ================================================ FILE: src/browser/webapi/collections/RadioNodeList.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const Input = @import("../element/html/Input.zig"); const NodeList = @import("NodeList.zig"); const HTMLFormControlsCollection = @import("HTMLFormControlsCollection.zig"); const RadioNodeList = @This(); _proto: *NodeList, _name: []const u8, _form_collection: *HTMLFormControlsCollection, pub fn getLength(self: *RadioNodeList) !u32 { var i: u32 = 0; var it = try self._form_collection.iterator(); while (it.next()) |element| { if (self.matches(element)) { i += 1; } } return i; } pub fn getAtIndex(self: *RadioNodeList, index: usize, page: *Page) !?*Node { var i: usize = 0; var current: usize = 0; while (self._form_collection.getAtIndex(i, page)) |element| : (i += 1) { if (!self.matches(element)) { continue; } if (current == index) { return element.asNode(); } current += 1; } return null; } pub fn getValue(self: *RadioNodeList) ![]const u8 { var it = try self._form_collection.iterator(); while (it.next()) |element| { const input = element.is(Input) orelse continue; if (input._input_type != .radio) { continue; } if (!input.getChecked()) { continue; } return element.getAttributeSafe(comptime .wrap("value")) orelse "on"; } return ""; } pub fn setValue(self: *RadioNodeList, value: []const u8, page: *Page) !void { var it = try self._form_collection.iterator(); while (it.next()) |element| { const input = element.is(Input) orelse continue; if (input._input_type != .radio) { continue; } const input_value = element.getAttributeSafe(comptime .wrap("value")); const matches_value = blk: { if (std.mem.eql(u8, value, "on")) { break :blk input_value == null or (input_value != null and std.mem.eql(u8, input_value.?, "on")); } else { break :blk input_value != null and std.mem.eql(u8, input_value.?, value); } }; if (matches_value) { try input.setChecked(true, page); return; } } } fn matches(self: *const RadioNodeList, element: *Element) bool { if (element.getAttributeSafe(comptime .wrap("id"))) |id| { if (std.mem.eql(u8, id, self._name)) { return true; } } if (element.getAttributeSafe(comptime .wrap("name"))) |elem_name| { if (std.mem.eql(u8, elem_name, self._name)) { return true; } } return false; } pub const JsApi = struct { pub const bridge = js.Bridge(RadioNodeList); pub const Meta = struct { pub const name = "RadioNodeList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const length = bridge.accessor(RadioNodeList.getLength, null, .{}); pub const @"[]" = bridge.indexed(RadioNodeList.getAtIndex, null, .{ .null_as_undefined = true }); pub const item = bridge.function(RadioNodeList.getAtIndex, .{}); pub const value = bridge.accessor(RadioNodeList.getValue, RadioNodeList.setValue, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: RadioNodeList" { try testing.htmlRunner("collections/radio_node_list.html", .{}); } ================================================ FILE: src/browser/webapi/collections/iterator.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { const R = reflect(Inner, field); return struct { inner: Inner, const Self = @This(); const Result = struct { done: bool, value: ?R.ValueType, pub const js_as_object = true; }; pub fn init(inner: Inner, page: *Page) !*Self { return page._factory.create(Self{ .inner = inner }); } pub fn deinit(self: *Self, shutdown: bool, session: *Session) void { if (@hasDecl(Inner, "deinit")) { self.inner.deinit(shutdown, session); } } pub fn acquireRef(self: *Self) void { if (@hasDecl(Inner, "acquireRef")) { self.inner.acquireRef(); } } pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result { const entry = (if (comptime R.has_error_return) try self.inner.next(page) else self.inner.next(page)) orelse { return .{ .done = true, .value = null }; }; if (comptime field == null) { return .{ .done = false, .value = entry }; } return .{ .done = false, .value = @field(entry, field.?), }; } pub const JsApi = struct { pub const bridge = js.Bridge(Self); pub const Meta = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(Self.deinit); }; pub const next = bridge.function(Self.next, .{ .null_as_undefined = true }); pub const symbol_iterator = bridge.iterator(Self, .{}); }; }; } fn reflect(comptime Inner: type, comptime field: ?[]const u8) Reflect { const R = @typeInfo(@TypeOf(Inner.next)).@"fn".return_type.?; const has_error_return = @typeInfo(R) == .error_union; return .{ .has_error_return = has_error_return, .ValueType = ValueType(unwrapOptional(unwrapError(R)), field), }; } const Reflect = struct { has_error_return: bool, ValueType: type, }; fn unwrapError(comptime T: type) type { if (@typeInfo(T) == .error_union) { return @typeInfo(T).error_union.payload; } return T; } fn unwrapOptional(comptime T: type) type { return @typeInfo(T).optional.child; } fn ValueType(comptime R: type, comptime field_: ?[]const u8) type { const field = field_ orelse return R; inline for (@typeInfo(R).@"struct".fields) |f| { if (comptime std.mem.eql(u8, f.name, field)) { return f.type; } } @compileError("Unknown EntryIterator field " ++ @typeName(R) ++ "." ++ field); } ================================================ FILE: src/browser/webapi/collections/node_live.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const Selector = @import("../selector/Selector.zig"); const Form = @import("../element/html/Form.zig"); const Mode = enum { tag, tag_name, tag_name_ns, class_name, name, all_elements, child_elements, child_tag, selected_options, links, anchors, form, }; pub const TagNameNsFilter = struct { namespace: ?Element.Namespace, // null means wildcard "*" local_name: String, }; const Filters = union(Mode) { tag: Element.Tag, tag_name: String, tag_name_ns: TagNameNsFilter, class_name: [][]const u8, name: []const u8, all_elements, child_elements, child_tag: Element.Tag, selected_options, links, anchors, form: *Form, fn TypeOf(comptime mode: Mode) type { @setEvalBranchQuota(2000); return std.meta.fieldInfo(Filters, mode).type; } }; // Operations on the live DOM can be inefficient. Do we really have to walk // through the entire tree, filtering out elements we don't care about, every // time .length is called? // To improve this, we track the "version" of the DOM (root.version). If the // version changes between operations, than we have to restart and pay the full // price. // But, if the version hasn't changed, then we can leverage other stateful data // to improve performance. For example, we cache the length property. So once // we've walked the tree to figure the length, we can re-use the cached property // if the DOM is unchanged (i.e. if our _cached_version == page.version). // // We do something similar for indexed getter (e.g. coll[4]), by preserving the // last node visited in the tree (implicitly by not resetting the TreeWalker). // If the DOM version is unchanged and the new index >= the last one, we can do // not have to reset our TreeWalker. This optimizes the common case of accessing // the collection via incrementing indexes. pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { .tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf, .child_elements, .child_tag, .selected_options => TreeWalker.Children, }; return struct { _tw: TW, _filter: Filter, _last_index: usize, _last_length: ?u32, _cached_version: usize, const Self = @This(); pub fn init(root: *Node, filter: Filter, page: *Page) Self { return .{ ._last_index = 0, ._last_length = null, ._filter = filter, ._tw = TW.init(root, .{}), ._cached_version = page.version, }; } pub fn length(self: *Self, page: *const Page) u32 { if (self.versionCheck(page)) { // the DOM version hasn't changed, use the cached version if // we have one if (self._last_length) |cached_length| { return cached_length; } // not ideal, but this can happen if list[x] is called followed // by list.length. self._tw.reset(); self._last_index = 0; } // If we're here, it means it's either the first time we're called // or the DOM version has changed. Either way, the _tw should be // at the start position. It's important that self._last_index == 0 // (which it always should be in these cases), because we're going to // reset _tw at the end of this, _last_index should always be 0 when // _tw is reset. Again, this should always be the case, but we're // asserting to make sure, else we'll have weird behavior, namely // the wrong item being returned for the wrong index. lp.assert(self._last_index == 0, "NodeLives.length", .{ .last_index = self._last_index }); var tw = &self._tw; defer tw.reset(); var l: u32 = 0; while (self.nextTw(tw)) |_| { l += 1; } self._last_length = l; return l; } // This API supports indexing by both numeric index and id/name // i.e. a combination of getAtIndex and getByName pub fn getIndexed(self: *Self, value: js.Atom, page: *Page) !?*Element { if (value.isUint()) |n| { return self.getAtIndex(n, page); } const name = value.toString(); defer value.freeString(name); return self.getByName(name, page) orelse return error.NotHandled; } pub fn getAtIndex(self: *Self, index: usize, page: *const Page) ?*Element { _ = self.versionCheck(page); var current = self._last_index; if (index <= current) { current = 0; self._tw.reset(); } defer self._last_index = current + 1; const tw = &self._tw; while (self.nextTw(tw)) |el| { if (index == current) { return el; } current += 1; } return null; } pub fn getByName(self: *Self, name: []const u8, page: *Page) ?*Element { if (page.document.getElementById(name, page)) |element| { const node = element.asNode(); if (self._tw.contains(node) and self.matches(node)) { return element; } } // Element not found by id, fallback to search by name. This isn't // efficient! // Gives us a TreeWalker based on the original, but reset to the // root. Doing this preserves any cache data we have for other calls // (like length or getAtIndex) var tw = self._tw.clone(); while (self.nextTw(&tw)) |element| { const element_name = element.getAttributeSafe(comptime .wrap("name")) orelse continue; if (std.mem.eql(u8, element_name, name)) { return element; } } return null; } pub fn next(self: *Self) ?*Element { return self.nextTw(&self._tw); } pub fn nextTw(self: *Self, tw: *TW) ?*Element { while (tw.next()) |node| { if (self.matches(node)) { return node.as(Element); } } return null; } fn matches(self: *const Self, node: *Node) bool { switch (mode) { .tag => { const el = node.is(Element) orelse return false; // For HTML namespace elements, we can use the optimized tag comparison. // For other namespaces (XML, SVG custom elements, etc.), fall back to string comparison. if (el._namespace == .html) { return el.getTag() == self._filter; } // For non-HTML elements, compare by tag name string const element_tag = el.getTagNameLower(); return std.mem.eql(u8, element_tag, @tagName(self._filter)); }, .tag_name => { // If we're in `tag_name` mode, then the tag_name isn't // a known tag. It could be a custom element, heading, or // any generic element. Compare against the element's tag name. // Per spec, getElementsByTagName is case-insensitive for HTML // namespace elements, case-sensitive for others. const el = node.is(Element) orelse return false; const element_tag = el.getTagNameLower(); if (el._namespace == .html) { return std.ascii.eqlIgnoreCase(element_tag, self._filter.str()); } return std.mem.eql(u8, element_tag, self._filter.str()); }, .tag_name_ns => { const el = node.is(Element) orelse return false; if (self._filter.namespace) |ns| { if (el._namespace != ns) return false; } // ok, namespace matches, check local name if (self._filter.local_name.eql(comptime .wrap("*"))) { // wildcard, match-all return true; } return self._filter.local_name.eqlSlice(el.getLocalName()); }, .class_name => { if (self._filter.len == 0) { return false; } const el = node.is(Element) orelse return false; const class_attr = el.getAttributeSafe(comptime .wrap("class")) orelse return false; for (self._filter) |class_name| { if (!Selector.classAttributeContains(class_attr, class_name)) { return false; } } return true; }, .name => { const el = node.is(Element) orelse return false; const name_attr = el.getAttributeSafe(comptime .wrap("name")) orelse return false; return std.mem.eql(u8, name_attr, self._filter); }, .all_elements => return node._type == .element, .child_elements => return node._type == .element, .child_tag => { const el = node.is(Element) orelse return false; return el.getTag() == self._filter; }, .selected_options => { const el = node.is(Element) orelse return false; const Option = Element.Html.Option; const opt = el.is(Option) orelse return false; return opt.getSelected(); }, .links => { // Links are <a> elements with href attribute (TODO: also <area> when implemented) const el = node.is(Element) orelse return false; const Anchor = Element.Html.Anchor; if (el.is(Anchor) == null) return false; return el.hasAttributeSafe(comptime .wrap("href")); }, .anchors => { // Anchors are <a> elements with name attribute const el = node.is(Element) orelse return false; const Anchor = Element.Html.Anchor; if (el.is(Anchor) == null) return false; return el.hasAttributeSafe(comptime .wrap("name")); }, .form => { const el = node.is(Element) orelse return false; if (!isFormControl(el)) { return false; } if (el.getAttributeSafe(comptime .wrap("form"))) |form_attr| { const form_id = self._filter.asElement().getAttributeSafe(comptime .wrap("id")) orelse return false; return std.mem.eql(u8, form_attr, form_id); } // No form attribute - match if descendant of our form // This does an O(depth) ancestor walk for each control in the form. // // TODO: If profiling shows this is a bottleneck: // When we first encounter the form element during tree walk, we could // do a one-time reverse walk to find the LAST control that belongs to // this form (checking both form controls and their form= attributes). // Store that element in a new FormState. Then as we traverse // forward: // - Set is_within_form = true when we enter the form element // - Return true immediately for any control while is_within_form // - Set is_within_form = false when we reach that last element // This trades one O(form_size) reverse walk for N O(depth) ancestor // checks, where N = number of controls. For forms with many nested // controls, this could be significantly faster. return self._filter.asNode().contains(node); }, } } fn isFormControl(el: *Element) bool { if (el._type != .html) return false; const html = el._type.html; return switch (html._type) { .input, .button, .select, .textarea => true, else => false, }; } fn versionCheck(self: *Self, page: *const Page) bool { const current = page.version; if (current == self._cached_version) { return true; } self._tw.reset(); self._last_index = 0; self._last_length = null; self._cached_version = current; return false; } const HTMLCollection = @import("HTMLCollection.zig"); const NodeList = @import("NodeList.zig"); pub fn runtimeGenericWrap(self: Self, page: *Page) !if (mode == .name) *NodeList else *HTMLCollection { const collection = switch (mode) { .name => return page._factory.create(NodeList{ ._data = .{ .name = self } }), .tag => HTMLCollection{ ._data = .{ .tag = self } }, .tag_name => HTMLCollection{ ._data = .{ .tag_name = self } }, .tag_name_ns => HTMLCollection{ ._data = .{ .tag_name_ns = self } }, .class_name => HTMLCollection{ ._data = .{ .class_name = self } }, .all_elements => HTMLCollection{ ._data = .{ .all_elements = self } }, .child_elements => HTMLCollection{ ._data = .{ .child_elements = self } }, .child_tag => HTMLCollection{ ._data = .{ .child_tag = self } }, .selected_options => HTMLCollection{ ._data = .{ .selected_options = self } }, .links => HTMLCollection{ ._data = .{ .links = self } }, .anchors => HTMLCollection{ ._data = .{ .anchors = self } }, .form => HTMLCollection{ ._data = .{ .form = self } }, }; return page._factory.create(collection); } }; } ================================================ FILE: src/browser/webapi/collections.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. pub const NodeLive = @import("collections/node_live.zig").NodeLive; pub const ChildNodes = @import("collections/ChildNodes.zig"); pub const DOMTokenList = @import("collections/DOMTokenList.zig"); pub const RadioNodeList = @import("collections/RadioNodeList.zig"); pub const HTMLCollection = @import("collections/HTMLCollection.zig"); pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig"); pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig"); pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig"); pub fn registerTypes() []const type { return &.{ HTMLCollection, HTMLCollection.Iterator, @import("collections/NodeList.zig"), @import("collections/NodeList.zig").KeyIterator, @import("collections/NodeList.zig").ValueIterator, @import("collections/NodeList.zig").EntryIterator, @import("collections/HTMLAllCollection.zig"), @import("collections/HTMLAllCollection.zig").Iterator, HTMLOptionsCollection, HTMLFormControlsCollection, RadioNodeList, DOMTokenList, DOMTokenList.KeyIterator, DOMTokenList.ValueIterator, DOMTokenList.EntryIterator, }; } ================================================ FILE: src/browser/webapi/css/CSSRule.zig ================================================ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CSSRule = @This(); pub const Type = enum(u16) { style = 1, charset = 2, import = 3, media = 4, font_face = 5, page = 6, keyframes = 7, keyframe = 8, margin = 9, namespace = 10, counter_style = 11, supports = 12, document = 13, font_feature_values = 14, viewport = 15, region_style = 16, }; _type: Type, pub fn init(rule_type: Type, page: *Page) !*CSSRule { return page._factory.create(CSSRule{ ._type = rule_type, }); } pub fn getType(self: *const CSSRule) u16 { return @intFromEnum(self._type); } pub fn getCssText(self: *const CSSRule, page: *Page) []const u8 { _ = self; _ = page; return ""; } pub fn setCssText(self: *CSSRule, text: []const u8, page: *Page) !void { _ = self; _ = text; _ = page; } pub fn getParentRule(self: *const CSSRule) ?*CSSRule { _ = self; return null; } pub fn getParentStyleSheet(self: *const CSSRule) ?*CSSRule { _ = self; return null; } pub const JsApi = struct { pub const bridge = js.Bridge(CSSRule); pub const Meta = struct { pub const name = "CSSRule"; pub var class_id: bridge.ClassId = undefined; pub const prototype_chain = bridge.prototypeChain(); }; pub const STYLE_RULE = 1; pub const CHARSET_RULE = 2; pub const IMPORT_RULE = 3; pub const MEDIA_RULE = 4; pub const FONT_FACE_RULE = 5; pub const PAGE_RULE = 6; pub const KEYFRAMES_RULE = 7; pub const KEYFRAME_RULE = 8; pub const MARGIN_RULE = 9; pub const NAMESPACE_RULE = 10; pub const COUNTER_STYLE_RULE = 11; pub const SUPPORTS_RULE = 12; pub const DOCUMENT_RULE = 13; pub const FONT_FEATURE_VALUES_RULE = 14; pub const VIEWPORT_RULE = 15; pub const REGION_STYLE_RULE = 16; pub const @"type" = bridge.accessor(CSSRule.getType, null, .{}); pub const cssText = bridge.accessor(CSSRule.getCssText, CSSRule.setCssText, .{}); pub const parentRule = bridge.accessor(CSSRule.getParentRule, null, .{}); pub const parentStyleSheet = bridge.accessor(CSSRule.getParentStyleSheet, null, .{}); }; ================================================ FILE: src/browser/webapi/css/CSSRuleList.zig ================================================ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CSSRule = @import("CSSRule.zig"); const CSSRuleList = @This(); _rules: []*CSSRule = &.{}, pub fn init(page: *Page) !*CSSRuleList { return page._factory.create(CSSRuleList{}); } pub fn length(self: *const CSSRuleList) u32 { return @intCast(self._rules.len); } pub fn item(self: *const CSSRuleList, index: usize) ?*CSSRule { if (index >= self._rules.len) { return null; } return self._rules[index]; } pub const JsApi = struct { pub const bridge = js.Bridge(CSSRuleList); pub const Meta = struct { pub const name = "CSSRuleList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const length = bridge.accessor(CSSRuleList.length, null, .{}); pub const @"[]" = bridge.indexed(CSSRuleList.item, null, .{ .null_as_undefined = true }); }; ================================================ FILE: src/browser/webapi/css/CSSStyleDeclaration.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const String = @import("../../../string.zig").String; const CssParser = @import("../../css/Parser.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const Allocator = std.mem.Allocator; const CSSStyleDeclaration = @This(); _element: ?*Element = null, _properties: std.DoublyLinkedList = .{}, _is_computed: bool = false, pub fn init(element: ?*Element, is_computed: bool, page: *Page) !*CSSStyleDeclaration { const self = try page._factory.create(CSSStyleDeclaration{ ._element = element, ._is_computed = is_computed, }); // Parse the element's existing style attribute into _properties so that // subsequent JS reads and writes see all CSS properties, not just newly // added ones. Computed styles have no inline attribute to parse. if (!is_computed) { if (element) |el| { if (el.getAttributeSafe(comptime .wrap("style"))) |attr_value| { var it = CssParser.parseDeclarationsList(attr_value); while (it.next()) |declaration| { try self.setPropertyImpl(declaration.name, declaration.value, declaration.important, page); } } } } return self; } pub fn length(self: *const CSSStyleDeclaration) u32 { return @intCast(self._properties.len()); } pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 { var i: u32 = 0; var node = self._properties.first; while (node) |n| { if (i == index) { const prop = Property.fromNodeLink(n); return prop._name.str(); } i += 1; node = n.next; } return ""; } pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); const prop = self.findProperty(normalized) orelse { // Only return default values for computed styles if (self._is_computed) { return getDefaultPropertyValue(self, normalized); } return ""; }; return prop._value.str(); } pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); const prop = self.findProperty(normalized) orelse return ""; return if (prop._important) "important" else ""; } pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value: []const u8, priority_: ?[]const u8, page: *Page) !void { // Validate priority const priority = priority_ orelse ""; const important = if (priority.len > 0) blk: { if (!std.ascii.eqlIgnoreCase(priority, "important")) { return; } break :blk true; } else false; try self.setPropertyImpl(property_name, value, important, page); try self.syncStyleAttribute(page); } fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: []const u8, important: bool, page: *Page) !void { if (value.len == 0) { _ = try self.removePropertyImpl(property_name, page); return; } const normalized = normalizePropertyName(property_name, &page.buf); // Normalize the value for canonical serialization const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value); // Find existing property if (self.findProperty(normalized)) |existing| { existing._value = try String.init(page.arena, normalized_value, .{}); existing._important = important; return; } // Create new property const prop = try page._factory.create(Property{ ._node = .{}, ._name = try String.init(page.arena, normalized, .{}), ._value = try String.init(page.arena, normalized_value, .{}), ._important = important, }); self._properties.append(&prop._node); } pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 { const result = try self.removePropertyImpl(property_name, page); try self.syncStyleAttribute(page); return result; } fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 { const normalized = normalizePropertyName(property_name, &page.buf); const prop = self.findProperty(normalized) orelse return ""; // the value might not be on the heap (it could be inlined in the small string // optimization), so we need to dupe it. const old_value = try page.call_arena.dupe(u8, prop._value.str()); self._properties.remove(&prop._node); page._factory.destroy(prop); return old_value; } // Serialize current properties back to the element's style attribute so that // DOM serialization (outerHTML, getAttribute) reflects JS-modified styles. fn syncStyleAttribute(self: *CSSStyleDeclaration, page: *Page) !void { const element = self._element orelse return; const css_text = try self.getCssText(page); try element.setAttributeSafe(comptime .wrap("style"), .wrap(css_text), page); } pub fn getFloat(self: *const CSSStyleDeclaration, page: *Page) []const u8 { return self.getPropertyValue("float", page); } pub fn setFloat(self: *CSSStyleDeclaration, value_: ?[]const u8, page: *Page) !void { try self.setPropertyImpl("float", value_ orelse "", false, page); try self.syncStyleAttribute(page); } pub fn getCssText(self: *const CSSStyleDeclaration, page: *Page) ![]const u8 { if (self._element == null) return ""; var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.format(&buf.writer); return buf.written(); } pub fn setCssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void { if (self._element == null) return; // Clear existing properties var node = self._properties.first; while (node) |n| { const next = n.next; const prop = Property.fromNodeLink(n); self._properties.remove(n); page._factory.destroy(prop); node = next; } // Parse and set new properties var it = CssParser.parseDeclarationsList(text); while (it.next()) |declaration| { try self.setPropertyImpl(declaration.name, declaration.value, declaration.important, page); } try self.syncStyleAttribute(page); } pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void { const node = self._properties.first orelse return; try Property.fromNodeLink(node).format(writer); var next = node.next; while (next) |n| { try writer.writeByte(' '); try Property.fromNodeLink(n).format(writer); next = n.next; } } fn findProperty(self: *const CSSStyleDeclaration, name: []const u8) ?*Property { var node = self._properties.first; while (node) |n| { const prop = Property.fromNodeLink(n); if (prop._name.eqlSlice(name)) { return prop; } node = n.next; } return null; } fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 { if (name.len > buf.len) { log.info(.dom, "css.long.name", .{ .name = name }); return name; } return std.ascii.lowerString(buf, name); } // Normalize CSS property values for canonical serialization fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: []const u8) ![]const u8 { // Per CSSOM spec, unitless zero in length properties should serialize as "0px" if (std.mem.eql(u8, value, "0") and isLengthProperty(property_name)) { return "0px"; } // "first baseline" serializes canonically as "baseline" (first is the default) if (std.ascii.startsWithIgnoreCase(value, "first baseline")) { if (value.len == 14) { // Exact match "first baseline" return "baseline"; } if (value.len > 14 and value[14] == ' ') { // "first baseline X" -> "baseline X" return try std.mem.concat(arena, u8, &.{ "baseline", value[14..] }); } } // For 2-value shorthand properties, collapse "X X" to "X" if (isTwoValueShorthand(property_name)) { if (collapseDuplicateValue(value)) |single| { return single; } } // Canonicalize anchor-size() function: anchor name (dashed ident) comes before size keyword if (std.mem.indexOf(u8, value, "anchor-size(") != null) { return try canonicalizeAnchorSize(arena, value); } return value; } // Canonicalize anchor-size() so that the dashed ident (anchor name) comes before the size keyword. // e.g. "anchor-size(width --foo)" -> "anchor-size(--foo width)" fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 { var buf = std.Io.Writer.Allocating.init(arena); var i: usize = 0; while (i < value.len) { // Look for "anchor-size(" if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { try buf.writer.writeAll("anchor-size("); i += "anchor-size(".len; // Parse and canonicalize the arguments i = try canonicalizeAnchorSizeArgs(value, i, &buf.writer); } else { try buf.writer.writeByte(value[i]); i += 1; } } return buf.written(); } // Parse anchor-size arguments and write them in canonical order fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.Writer) !usize { var i = start; var depth: usize = 1; // Skip leading whitespace while (i < value.len and value[i] == ' ') : (i += 1) {} // Collect tokens before the comma or close paren var first_token_start: ?usize = null; var first_token_end: usize = 0; var second_token_start: ?usize = null; var second_token_end: usize = 0; var comma_pos: ?usize = null; var token_count: usize = 0; const args_start = i; var in_token = false; // First pass: find the structure of arguments before comma/closing paren at depth 1 while (i < value.len and depth > 0) { const c = value[i]; if (c == '(') { depth += 1; in_token = true; i += 1; } else if (c == ')') { depth -= 1; if (depth == 0) { if (in_token) { if (token_count == 0) { first_token_end = i; } else if (token_count == 1) { second_token_end = i; } } break; } i += 1; } else if (c == ',' and depth == 1) { if (in_token) { if (token_count == 0) { first_token_end = i; } else if (token_count == 1) { second_token_end = i; } } comma_pos = i; break; } else if (c == ' ') { if (in_token and depth == 1) { if (token_count == 0) { first_token_end = i; token_count = 1; } else if (token_count == 1 and second_token_start != null) { second_token_end = i; token_count = 2; } in_token = false; } i += 1; } else { if (!in_token and depth == 1) { if (token_count == 0) { first_token_start = i; } else if (token_count == 1) { second_token_start = i; } in_token = true; } i += 1; } } // Handle end of tokens if (in_token and token_count == 1 and second_token_start != null) { second_token_end = i; token_count = 2; } else if (in_token and token_count == 0) { first_token_end = i; token_count = 1; } // Check if we have exactly two tokens that need reordering if (token_count == 2) { const first_start = first_token_start orelse args_start; const second_start = second_token_start orelse first_token_end; const first_token = value[first_start..first_token_end]; const second_token = value[second_start..second_token_end]; // If second token is a dashed ident and first is a size keyword, swap them if (std.mem.startsWith(u8, second_token, "--") and isAnchorSizeKeyword(first_token)) { try writer.writeAll(second_token); try writer.writeByte(' '); try writer.writeAll(first_token); } else { // Keep original order try writer.writeAll(first_token); try writer.writeByte(' '); try writer.writeAll(second_token); } } else if (first_token_start) |fts| { // Single token, just copy it try writer.writeAll(value[fts..first_token_end]); } // Handle comma and fallback value (may contain nested anchor-size) if (comma_pos) |cp| { try writer.writeAll(", "); i = cp + 1; // Skip whitespace after comma while (i < value.len and value[i] == ' ') : (i += 1) {} // Copy the fallback, recursively handling nested anchor-size while (i < value.len and depth > 0) { if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { try writer.writeAll("anchor-size("); i += "anchor-size(".len; depth += 1; i = try canonicalizeAnchorSizeArgs(value, i, writer); depth -= 1; } else if (value[i] == '(') { depth += 1; try writer.writeByte(value[i]); i += 1; } else if (value[i] == ')') { depth -= 1; if (depth == 0) break; try writer.writeByte(value[i]); i += 1; } else { try writer.writeByte(value[i]); i += 1; } } } // Write closing paren try writer.writeByte(')'); return i + 1; // Skip past the closing paren } fn isAnchorSizeKeyword(token: []const u8) bool { const keywords = std.StaticStringMap(void).initComptime(.{ .{ "width", {} }, .{ "height", {} }, .{ "block", {} }, .{ "inline", {} }, .{ "self-block", {} }, .{ "self-inline", {} }, }); return keywords.has(token); } // Check if a value is "X X" (duplicate) and return just "X" fn collapseDuplicateValue(value: []const u8) ?[]const u8 { const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null; if (space_idx == 0 or space_idx >= value.len - 1) return null; const first = value[0..space_idx]; const rest = std.mem.trimLeft(u8, value[space_idx + 1 ..], " "); // Check if there's only one more value (no additional spaces) if (std.mem.indexOfScalar(u8, rest, ' ') != null) return null; if (std.mem.eql(u8, first, rest)) { return first; } return null; } fn isTwoValueShorthand(name: []const u8) bool { const shorthands = std.StaticStringMap(void).initComptime(.{ .{ "place-content", {} }, .{ "place-items", {} }, .{ "place-self", {} }, .{ "margin-block", {} }, .{ "margin-inline", {} }, .{ "padding-block", {} }, .{ "padding-inline", {} }, .{ "inset-block", {} }, .{ "inset-inline", {} }, .{ "border-block-style", {} }, .{ "border-inline-style", {} }, .{ "border-block-width", {} }, .{ "border-inline-width", {} }, .{ "border-block-color", {} }, .{ "border-inline-color", {} }, .{ "overflow", {} }, .{ "overscroll-behavior", {} }, .{ "gap", {} }, .{ "grid-gap", {} }, // Scroll .{ "scroll-padding-block", {} }, .{ "scroll-padding-inline", {} }, .{ "scroll-snap-align", {} }, // Background/Mask .{ "background-size", {} }, .{ "border-image-repeat", {} }, .{ "mask-repeat", {} }, .{ "mask-size", {} }, }); return shorthands.has(name); } fn isLengthProperty(name: []const u8) bool { // Properties that accept <length> or <length-percentage> values const length_properties = std.StaticStringMap(void).initComptime(.{ // Sizing .{ "width", {} }, .{ "height", {} }, .{ "min-width", {} }, .{ "min-height", {} }, .{ "max-width", {} }, .{ "max-height", {} }, // Margins .{ "margin", {} }, .{ "margin-top", {} }, .{ "margin-right", {} }, .{ "margin-bottom", {} }, .{ "margin-left", {} }, .{ "margin-block", {} }, .{ "margin-block-start", {} }, .{ "margin-block-end", {} }, .{ "margin-inline", {} }, .{ "margin-inline-start", {} }, .{ "margin-inline-end", {} }, // Padding .{ "padding", {} }, .{ "padding-top", {} }, .{ "padding-right", {} }, .{ "padding-bottom", {} }, .{ "padding-left", {} }, .{ "padding-block", {} }, .{ "padding-block-start", {} }, .{ "padding-block-end", {} }, .{ "padding-inline", {} }, .{ "padding-inline-start", {} }, .{ "padding-inline-end", {} }, // Positioning .{ "top", {} }, .{ "right", {} }, .{ "bottom", {} }, .{ "left", {} }, .{ "inset", {} }, .{ "inset-block", {} }, .{ "inset-block-start", {} }, .{ "inset-block-end", {} }, .{ "inset-inline", {} }, .{ "inset-inline-start", {} }, .{ "inset-inline-end", {} }, // Border .{ "border-width", {} }, .{ "border-top-width", {} }, .{ "border-right-width", {} }, .{ "border-bottom-width", {} }, .{ "border-left-width", {} }, .{ "border-block-width", {} }, .{ "border-block-start-width", {} }, .{ "border-block-end-width", {} }, .{ "border-inline-width", {} }, .{ "border-inline-start-width", {} }, .{ "border-inline-end-width", {} }, .{ "border-radius", {} }, .{ "border-top-left-radius", {} }, .{ "border-top-right-radius", {} }, .{ "border-bottom-left-radius", {} }, .{ "border-bottom-right-radius", {} }, // Text .{ "font-size", {} }, .{ "letter-spacing", {} }, .{ "word-spacing", {} }, .{ "text-indent", {} }, // Flexbox/Grid .{ "gap", {} }, .{ "row-gap", {} }, .{ "column-gap", {} }, .{ "flex-basis", {} }, // Legacy grid aliases .{ "grid-column-gap", {} }, .{ "grid-row-gap", {} }, // Outline .{ "outline", {} }, .{ "outline-width", {} }, .{ "outline-offset", {} }, // Multi-column .{ "column-rule-width", {} }, .{ "column-width", {} }, // Scroll .{ "scroll-margin", {} }, .{ "scroll-margin-top", {} }, .{ "scroll-margin-right", {} }, .{ "scroll-margin-bottom", {} }, .{ "scroll-margin-left", {} }, .{ "scroll-padding", {} }, .{ "scroll-padding-top", {} }, .{ "scroll-padding-right", {} }, .{ "scroll-padding-bottom", {} }, .{ "scroll-padding-left", {} }, // Shapes .{ "shape-margin", {} }, // Motion path .{ "offset-distance", {} }, // Transforms .{ "translate", {} }, // Animations .{ "animation-range-end", {} }, .{ "animation-range-start", {} }, // Other .{ "border-spacing", {} }, .{ "text-shadow", {} }, .{ "box-shadow", {} }, .{ "baseline-shift", {} }, .{ "vertical-align", {} }, .{ "text-decoration-inset", {} }, .{ "block-step-size", {} }, // Grid lanes .{ "flow-tolerance", {} }, .{ "column-rule-edge-inset", {} }, .{ "column-rule-interior-inset", {} }, .{ "row-rule-edge-inset", {} }, .{ "row-rule-interior-inset", {} }, .{ "rule-edge-inset", {} }, .{ "rule-interior-inset", {} }, }); return length_properties.has(name); } fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 { if (std.mem.eql(u8, normalized_name, "visibility")) { return "visible"; } if (std.mem.eql(u8, normalized_name, "opacity")) { return "1"; } if (std.mem.eql(u8, normalized_name, "display")) { const element = self._element orelse return ""; return getDefaultDisplay(element); } if (std.mem.eql(u8, normalized_name, "color")) { const element = self._element orelse return ""; return getDefaultColor(element); } if (std.mem.eql(u8, normalized_name, "background-color")) { // transparent return "rgba(0, 0, 0, 0)"; } return ""; } fn getDefaultDisplay(element: *const Element) []const u8 { switch (element._type) { .html => |html| { return switch (html._type) { .anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline", .body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .legend, .map, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block", .generic, .custom, .unknown, .data => blk: { const tag = element.getTagNameLower(); if (isInlineTag(tag)) break :blk "inline"; break :blk "block"; }, }; }, .svg => return "inline", } } fn isInlineTag(tag_name: []const u8) bool { const inline_tags = [_][]const u8{ "abbr", "b", "bdi", "bdo", "cite", "code", "dfn", "em", "i", "kbd", "mark", "q", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", }; for (inline_tags) |inline_tag| { if (std.mem.eql(u8, tag_name, inline_tag)) { return true; } } return false; } fn getDefaultColor(element: *const Element) []const u8 { switch (element._type) { .html => |html| { return switch (html._type) { .anchor => "rgb(0, 0, 238)", // blue else => "rgb(0, 0, 0)", }; }, .svg => return "rgb(0, 0, 0)", } } pub const Property = struct { _name: String, _value: String, _important: bool = false, _node: std.DoublyLinkedList.Node, fn fromNodeLink(n: *std.DoublyLinkedList.Node) *Property { return @alignCast(@fieldParentPtr("_node", n)); } pub fn format(self: *const Property, writer: *std.Io.Writer) !void { try self._name.format(writer); try writer.writeAll(": "); try self._value.format(writer); if (self._important) { try writer.writeAll(" !important"); } try writer.writeByte(';'); } }; pub const JsApi = struct { pub const bridge = js.Bridge(CSSStyleDeclaration); pub const Meta = struct { pub const name = "CSSStyleDeclaration"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const cssText = bridge.accessor(CSSStyleDeclaration.getCssText, CSSStyleDeclaration.setCssText, .{}); pub const length = bridge.accessor(CSSStyleDeclaration.length, null, .{}); pub const item = bridge.function(_item, .{}); fn _item(self: *const CSSStyleDeclaration, index: i32) []const u8 { if (index < 0) { return ""; } return self.item(@intCast(index)); } pub const getPropertyValue = bridge.function(CSSStyleDeclaration.getPropertyValue, .{}); pub const getPropertyPriority = bridge.function(CSSStyleDeclaration.getPropertyPriority, .{}); pub const setProperty = bridge.function(CSSStyleDeclaration.setProperty, .{}); pub const removeProperty = bridge.function(CSSStyleDeclaration.removeProperty, .{}); pub const cssFloat = bridge.accessor(CSSStyleDeclaration.getFloat, CSSStyleDeclaration.setFloat, .{}); }; const testing = @import("std").testing; test "normalizePropertyValue: unitless zero to 0px" { const cases = .{ .{ "width", "0", "0px" }, .{ "height", "0", "0px" }, .{ "scroll-margin-top", "0", "0px" }, .{ "scroll-padding-bottom", "0", "0px" }, .{ "column-width", "0", "0px" }, .{ "column-rule-width", "0", "0px" }, .{ "outline", "0", "0px" }, .{ "shape-margin", "0", "0px" }, .{ "offset-distance", "0", "0px" }, .{ "translate", "0", "0px" }, .{ "grid-column-gap", "0", "0px" }, .{ "grid-row-gap", "0", "0px" }, // Non-length properties should NOT normalize .{ "opacity", "0", "0" }, .{ "z-index", "0", "0" }, }; inline for (cases) |case| { const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); try testing.expectEqualStrings(case[2], result); } } test "normalizePropertyValue: first baseline to baseline" { const result = try normalizePropertyValue(testing.allocator, "align-items", "first baseline"); try testing.expectEqualStrings("baseline", result); const result2 = try normalizePropertyValue(testing.allocator, "align-self", "last baseline"); try testing.expectEqualStrings("last baseline", result2); } test "normalizePropertyValue: collapse duplicate two-value shorthands" { const cases = .{ .{ "overflow", "hidden hidden", "hidden" }, .{ "gap", "10px 10px", "10px" }, .{ "scroll-snap-align", "start start", "start" }, .{ "scroll-padding-block", "5px 5px", "5px" }, .{ "background-size", "auto auto", "auto" }, .{ "overscroll-behavior", "auto auto", "auto" }, // Different values should NOT collapse .{ "overflow", "hidden scroll", "hidden scroll" }, .{ "gap", "10px 20px", "10px 20px" }, }; inline for (cases) |case| { const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); try testing.expectEqualStrings(case[2], result); } } ================================================ FILE: src/browser/webapi/css/CSSStyleProperties.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Element = @import("../Element.zig"); const Page = @import("../../Page.zig"); const CSSStyleDeclaration = @import("CSSStyleDeclaration.zig"); const CSSStyleProperties = @This(); _proto: *CSSStyleDeclaration, pub fn init(element: ?*Element, is_computed: bool, page: *Page) !*CSSStyleProperties { return page._factory.create(CSSStyleProperties{ ._proto = try CSSStyleDeclaration.init(element, is_computed, page), }); } pub fn asCSSStyleDeclaration(self: *CSSStyleProperties) *CSSStyleDeclaration { return self._proto; } pub fn setNamed(self: *CSSStyleProperties, name: []const u8, value: []const u8, page: *Page) !void { if (method_names.has(name)) { return error.NotHandled; } const dash_case = camelCaseToDashCase(name, &page.buf); try self._proto.setProperty(dash_case, value, null, page); } pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]const u8 { if (method_names.has(name)) { return error.NotHandled; } const dash_case = camelCaseToDashCase(name, &page.buf); // Only apply vendor prefix filtering for camelCase access (no dashes in input) // Bracket notation with dash-case (e.g., div.style['-moz-user-select']) should return the actual value const is_camelcase_access = std.mem.indexOfScalar(u8, name, '-') == null; if (is_camelcase_access and std.mem.startsWith(u8, dash_case, "-")) { // We only support -webkit-, other vendor prefixes return undefined for camelCase access const is_webkit = std.mem.startsWith(u8, dash_case, "-webkit-"); const is_moz = std.mem.startsWith(u8, dash_case, "-moz-"); const is_ms = std.mem.startsWith(u8, dash_case, "-ms-"); const is_o = std.mem.startsWith(u8, dash_case, "-o-"); if ((is_moz or is_ms or is_o) and !is_webkit) { return error.NotHandled; } } const value = self._proto.getPropertyValue(dash_case, page); // Property accessors have special handling for empty values: // - Known CSS properties return '' when not set // - Vendor-prefixed properties return undefined when not set // - Unknown properties return undefined if (value.len == 0) { // Vendor-prefixed properties always return undefined when not set if (std.mem.startsWith(u8, dash_case, "-")) { return error.NotHandled; } // Known CSS properties return '', unknown properties return undefined if (!isKnownCSSProperty(dash_case)) { return error.NotHandled; } return ""; } return value; } fn isKnownCSSProperty(dash_case: []const u8) bool { const known_properties = std.StaticStringMap(void).initComptime(.{ // Colors & backgrounds .{ "color", {} }, .{ "background", {} }, .{ "background-color", {} }, .{ "background-image", {} }, .{ "background-position", {} }, .{ "background-repeat", {} }, .{ "background-size", {} }, .{ "background-attachment", {} }, .{ "background-clip", {} }, .{ "background-origin", {} }, // Typography .{ "font", {} }, .{ "font-family", {} }, .{ "font-size", {} }, .{ "font-style", {} }, .{ "font-weight", {} }, .{ "font-variant", {} }, .{ "line-height", {} }, .{ "letter-spacing", {} }, .{ "word-spacing", {} }, .{ "text-align", {} }, .{ "text-decoration", {} }, .{ "text-indent", {} }, .{ "text-transform", {} }, .{ "white-space", {} }, .{ "word-break", {} }, .{ "word-wrap", {} }, .{ "overflow-wrap", {} }, // Box model .{ "margin", {} }, .{ "margin-top", {} }, .{ "margin-right", {} }, .{ "margin-bottom", {} }, .{ "margin-left", {} }, .{ "margin-block", {} }, .{ "margin-block-start", {} }, .{ "margin-block-end", {} }, .{ "margin-inline", {} }, .{ "margin-inline-start", {} }, .{ "margin-inline-end", {} }, .{ "padding", {} }, .{ "padding-top", {} }, .{ "padding-right", {} }, .{ "padding-bottom", {} }, .{ "padding-left", {} }, .{ "padding-block", {} }, .{ "padding-block-start", {} }, .{ "padding-block-end", {} }, .{ "padding-inline", {} }, .{ "padding-inline-start", {} }, .{ "padding-inline-end", {} }, // Border .{ "border", {} }, .{ "border-width", {} }, .{ "border-style", {} }, .{ "border-color", {} }, .{ "border-top", {} }, .{ "border-top-width", {} }, .{ "border-top-style", {} }, .{ "border-top-color", {} }, .{ "border-right", {} }, .{ "border-right-width", {} }, .{ "border-right-style", {} }, .{ "border-right-color", {} }, .{ "border-bottom", {} }, .{ "border-bottom-width", {} }, .{ "border-bottom-style", {} }, .{ "border-bottom-color", {} }, .{ "border-left", {} }, .{ "border-left-width", {} }, .{ "border-left-style", {} }, .{ "border-left-color", {} }, .{ "border-radius", {} }, .{ "border-top-left-radius", {} }, .{ "border-top-right-radius", {} }, .{ "border-bottom-left-radius", {} }, .{ "border-bottom-right-radius", {} }, .{ "border-collapse", {} }, .{ "border-spacing", {} }, // Sizing .{ "width", {} }, .{ "height", {} }, .{ "min-width", {} }, .{ "min-height", {} }, .{ "max-width", {} }, .{ "max-height", {} }, .{ "box-sizing", {} }, // Positioning .{ "position", {} }, .{ "top", {} }, .{ "right", {} }, .{ "bottom", {} }, .{ "left", {} }, .{ "inset", {} }, .{ "inset-block", {} }, .{ "inset-block-start", {} }, .{ "inset-block-end", {} }, .{ "inset-inline", {} }, .{ "inset-inline-start", {} }, .{ "inset-inline-end", {} }, .{ "z-index", {} }, .{ "float", {} }, .{ "clear", {} }, // Display & visibility .{ "display", {} }, .{ "visibility", {} }, .{ "opacity", {} }, .{ "overflow", {} }, .{ "overflow-x", {} }, .{ "overflow-y", {} }, .{ "clip", {} }, .{ "clip-path", {} }, // Flexbox .{ "flex", {} }, .{ "flex-direction", {} }, .{ "flex-wrap", {} }, .{ "flex-flow", {} }, .{ "flex-grow", {} }, .{ "flex-shrink", {} }, .{ "flex-basis", {} }, .{ "order", {} }, // Grid .{ "grid", {} }, .{ "grid-template", {} }, .{ "grid-template-columns", {} }, .{ "grid-template-rows", {} }, .{ "grid-template-areas", {} }, .{ "grid-auto-columns", {} }, .{ "grid-auto-rows", {} }, .{ "grid-auto-flow", {} }, .{ "grid-column", {} }, .{ "grid-column-start", {} }, .{ "grid-column-end", {} }, .{ "grid-row", {} }, .{ "grid-row-start", {} }, .{ "grid-row-end", {} }, .{ "grid-area", {} }, .{ "gap", {} }, .{ "row-gap", {} }, .{ "column-gap", {} }, // Alignment (flexbox & grid) .{ "align-content", {} }, .{ "align-items", {} }, .{ "align-self", {} }, .{ "justify-content", {} }, .{ "justify-items", {} }, .{ "justify-self", {} }, .{ "place-content", {} }, .{ "place-items", {} }, .{ "place-self", {} }, // Transforms & animations .{ "transform", {} }, .{ "transform-origin", {} }, .{ "transform-style", {} }, .{ "perspective", {} }, .{ "perspective-origin", {} }, .{ "transition", {} }, .{ "transition-property", {} }, .{ "transition-duration", {} }, .{ "transition-timing-function", {} }, .{ "transition-delay", {} }, .{ "animation", {} }, .{ "animation-name", {} }, .{ "animation-duration", {} }, .{ "animation-timing-function", {} }, .{ "animation-delay", {} }, .{ "animation-iteration-count", {} }, .{ "animation-direction", {} }, .{ "animation-fill-mode", {} }, .{ "animation-play-state", {} }, // Filters & effects .{ "filter", {} }, .{ "backdrop-filter", {} }, .{ "box-shadow", {} }, .{ "text-shadow", {} }, // Outline .{ "outline", {} }, .{ "outline-width", {} }, .{ "outline-style", {} }, .{ "outline-color", {} }, .{ "outline-offset", {} }, // Lists .{ "list-style", {} }, .{ "list-style-type", {} }, .{ "list-style-position", {} }, .{ "list-style-image", {} }, // Tables .{ "table-layout", {} }, .{ "caption-side", {} }, .{ "empty-cells", {} }, // Misc .{ "cursor", {} }, .{ "pointer-events", {} }, .{ "user-select", {} }, .{ "resize", {} }, .{ "object-fit", {} }, .{ "object-position", {} }, .{ "vertical-align", {} }, .{ "content", {} }, .{ "quotes", {} }, .{ "counter-reset", {} }, .{ "counter-increment", {} }, // Scrolling .{ "scroll-behavior", {} }, .{ "scroll-margin", {} }, .{ "scroll-padding", {} }, .{ "overscroll-behavior", {} }, .{ "overscroll-behavior-x", {} }, .{ "overscroll-behavior-y", {} }, // Containment .{ "contain", {} }, .{ "container", {} }, .{ "container-type", {} }, .{ "container-name", {} }, // Aspect ratio .{ "aspect-ratio", {} }, }); return known_properties.has(dash_case); } fn camelCaseToDashCase(name: []const u8, buf: []u8) []const u8 { if (name.len == 0) { return name; } // Special case: cssFloat -> float const lower_name = std.ascii.lowerString(buf, name); if (std.mem.eql(u8, lower_name, "cssfloat")) { return "float"; } // If already contains dashes, just return lowercased if (std.mem.indexOfScalar(u8, name, '-')) |_| { return lower_name; } // Check if this looks like proper camelCase (starts with lowercase) // If not (e.g. "COLOR", "BackgroundColor"), just lowercase it if (name.len == 0 or !std.ascii.isLower(name[0])) { return lower_name; } // Check for vendor prefixes: webkitTransform -> -webkit-transform // Must have uppercase letter after the prefix const has_vendor_prefix = blk: { if (name.len > 6 and std.mem.startsWith(u8, name, "webkit") and std.ascii.isUpper(name[6])) break :blk true; if (name.len > 3 and std.mem.startsWith(u8, name, "moz") and std.ascii.isUpper(name[3])) break :blk true; if (name.len > 2 and std.mem.startsWith(u8, name, "ms") and std.ascii.isUpper(name[2])) break :blk true; if (name.len > 1 and std.mem.startsWith(u8, name, "o") and std.ascii.isUpper(name[1])) break :blk true; break :blk false; }; var write_pos: usize = 0; if (has_vendor_prefix) { buf[write_pos] = '-'; write_pos += 1; } for (name, 0..) |c, i| { if (write_pos >= buf.len) { return lower_name; } if (std.ascii.isUpper(c)) { const skip_dash = has_vendor_prefix and i < 10 and write_pos == 1; if (i > 0 and !skip_dash) { if (write_pos >= buf.len) break; buf[write_pos] = '-'; write_pos += 1; } if (write_pos >= buf.len) break; buf[write_pos] = std.ascii.toLower(c); write_pos += 1; } else { buf[write_pos] = c; write_pos += 1; } } return buf[0..write_pos]; } const method_names = std.StaticStringMap(void).initComptime(.{ .{ "getPropertyValue", {} }, .{ "setProperty", {} }, .{ "removeProperty", {} }, .{ "getPropertyPriority", {} }, .{ "item", {} }, .{ "cssText", {} }, .{ "length", {} }, }); pub const JsApi = struct { pub const bridge = js.Bridge(CSSStyleProperties); pub const Meta = struct { pub const name = "CSSStyleProperties"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const @"[]" = bridge.namedIndexed(CSSStyleProperties.getNamed, CSSStyleProperties.setNamed, null, .{}); }; ================================================ FILE: src/browser/webapi/css/CSSStyleRule.zig ================================================ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CSSRule = @import("CSSRule.zig"); const CSSStyleDeclaration = @import("CSSStyleDeclaration.zig"); const CSSStyleRule = @This(); _proto: *CSSRule, _selector_text: []const u8 = "", _style: ?*CSSStyleDeclaration = null, pub fn init(page: *Page) !*CSSStyleRule { const rule = try CSSRule.init(.style, page); return page._factory.create(CSSStyleRule{ ._proto = rule, }); } pub fn getSelectorText(self: *const CSSStyleRule) []const u8 { return self._selector_text; } pub fn setSelectorText(self: *CSSStyleRule, text: []const u8, page: *Page) !void { self._selector_text = try page.dupeString(text); } pub fn getStyle(self: *CSSStyleRule, page: *Page) !*CSSStyleDeclaration { if (self._style) |style| { return style; } const style = try CSSStyleDeclaration.init(null, false, page); self._style = style; return style; } pub const JsApi = struct { pub const bridge = js.Bridge(CSSStyleRule); pub const Meta = struct { pub const name = "CSSStyleRule"; pub const prototype_chain = bridge.prototypeChain(CSSRule); pub var class_id: bridge.ClassId = undefined; }; pub const selectorText = bridge.accessor(CSSStyleRule.getSelectorText, CSSStyleRule.setSelectorText, .{}); pub const style = bridge.accessor(CSSStyleRule.getStyle, null, .{}); }; ================================================ FILE: src/browser/webapi/css/CSSStyleSheet.zig ================================================ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const CSSRuleList = @import("CSSRuleList.zig"); const CSSRule = @import("CSSRule.zig"); const CSSStyleSheet = @This(); _href: ?[]const u8 = null, _title: []const u8 = "", _disabled: bool = false, _css_rules: ?*CSSRuleList = null, _owner_rule: ?*CSSRule = null, _owner_node: ?*Element = null, pub fn init(page: *Page) !*CSSStyleSheet { return page._factory.create(CSSStyleSheet{}); } pub fn initWithOwner(owner: *Element, page: *Page) !*CSSStyleSheet { return page._factory.create(CSSStyleSheet{ ._owner_node = owner }); } pub fn getOwnerNode(self: *const CSSStyleSheet) ?*Element { return self._owner_node; } pub fn getHref(self: *const CSSStyleSheet) ?[]const u8 { return self._href; } pub fn getTitle(self: *const CSSStyleSheet) []const u8 { return self._title; } pub fn getDisabled(self: *const CSSStyleSheet) bool { return self._disabled; } pub fn setDisabled(self: *CSSStyleSheet, disabled: bool) void { self._disabled = disabled; } pub fn getCssRules(self: *CSSStyleSheet, page: *Page) !*CSSRuleList { if (self._css_rules) |rules| return rules; const rules = try CSSRuleList.init(page); self._css_rules = rules; return rules; } pub fn getOwnerRule(self: *const CSSStyleSheet) ?*CSSRule { return self._owner_rule; } pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, index: u32, page: *Page) !u32 { _ = self; _ = rule; _ = index; _ = page; return 0; } pub fn deleteRule(self: *CSSStyleSheet, index: u32, page: *Page) !void { _ = self; _ = index; _ = page; } pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) !js.Promise { _ = self; _ = text; // TODO: clear self.css_rules return page.js.local.?.resolvePromise({}); } pub fn replaceSync(self: *CSSStyleSheet, text: []const u8) !void { _ = self; _ = text; // TODO: clear self.css_rules } pub const JsApi = struct { pub const bridge = js.Bridge(CSSStyleSheet); pub const Meta = struct { pub const name = "CSSStyleSheet"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(CSSStyleSheet.init, .{}); pub const ownerNode = bridge.accessor(CSSStyleSheet.getOwnerNode, null, .{ .null_as_undefined = true }); pub const href = bridge.accessor(CSSStyleSheet.getHref, null, .{ .null_as_undefined = true }); pub const title = bridge.accessor(CSSStyleSheet.getTitle, null, .{}); pub const disabled = bridge.accessor(CSSStyleSheet.getDisabled, CSSStyleSheet.setDisabled, .{}); pub const cssRules = bridge.accessor(CSSStyleSheet.getCssRules, null, .{}); pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{}); pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{}); pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{}); pub const replace = bridge.function(CSSStyleSheet.replace, .{}); pub const replaceSync = bridge.function(CSSStyleSheet.replaceSync, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: CSSStyleSheet" { try testing.htmlRunner("css/stylesheet.html", .{}); } ================================================ FILE: src/browser/webapi/css/FontFace.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; const FontFace = @This(); _arena: Allocator, _family: []const u8, pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace { _ = source; const arena = try page.getArena(.{ .debug = "FontFace" }); errdefer page.releaseArena(arena); const self = try arena.create(FontFace); self.* = .{ ._arena = arena, ._family = try arena.dupe(u8, family), }; return self; } pub fn deinit(self: *FontFace, _: bool, session: *Session) void { session.releaseArena(self._arena); } pub fn getFamily(self: *const FontFace) []const u8 { return self._family; } // load() - resolves immediately; headless browser has no real font loading. pub fn load(_: *FontFace, page: *Page) !js.Promise { return page.js.local.?.resolvePromise({}); } // loaded - returns an already-resolved Promise. pub fn getLoaded(_: *FontFace, page: *Page) !js.Promise { return page.js.local.?.resolvePromise({}); } pub const JsApi = struct { pub const bridge = js.Bridge(FontFace); pub const Meta = struct { pub const name = "FontFace"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(FontFace.deinit); }; pub const constructor = bridge.constructor(FontFace.init, .{}); pub const family = bridge.accessor(FontFace.getFamily, null, .{}); pub const status = bridge.property("loaded", .{ .template = false, .readonly = true }); pub const style = bridge.property("normal", .{ .template = false, .readonly = true }); pub const weight = bridge.property("normal", .{ .template = false, .readonly = true }); pub const stretch = bridge.property("normal", .{ .template = false, .readonly = true }); pub const unicodeRange = bridge.property("U+0-10FFFF", .{ .template = false, .readonly = true }); pub const variant = bridge.property("normal", .{ .template = false, .readonly = true }); pub const featureSettings = bridge.property("normal", .{ .template = false, .readonly = true }); pub const display = bridge.property("auto", .{ .template = false, .readonly = true }); pub const loaded = bridge.accessor(FontFace.getLoaded, null, .{}); pub const load = bridge.function(FontFace.load, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: FontFace" { try testing.htmlRunner("css/font_face.html", .{}); } ================================================ FILE: src/browser/webapi/css/FontFaceSet.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const FontFace = @import("FontFace.zig"); const EventTarget = @import("../EventTarget.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; const FontFaceSet = @This(); _proto: *EventTarget, _arena: Allocator, pub fn init(page: *Page) !*FontFaceSet { const arena = try page.getArena(.{ .debug = "FontFaceSet" }); errdefer page.releaseArena(arena); return page._factory.eventTargetWithAllocator(arena, FontFaceSet{ ._proto = undefined, ._arena = arena, }); } pub fn deinit(self: *FontFaceSet, _: bool, session: *Session) void { session.releaseArena(self._arena); } pub fn asEventTarget(self: *FontFaceSet) *EventTarget { return self._proto; } // FontFaceSet.ready - returns an already-resolved Promise. // In a headless browser there is no font loading, so fonts are always ready. pub fn getReady(_: *FontFaceSet, page: *Page) !js.Promise { return page.js.local.?.resolvePromise({}); } // check(font, text?) - always true; headless has no real fonts to check. pub fn check(_: *const FontFaceSet, font: []const u8) bool { _ = font; return true; } // load(font, text?) - resolves immediately with an empty array. pub fn load(self: *FontFaceSet, font: []const u8, page: *Page) !js.Promise { // TODO parse font to check if the font has been added before dispatching // events. _ = font; // Dispatch loading event const target = self.asEventTarget(); if (page._event_manager.hasDirectListeners(target, "loading", null)) { const event = try Event.initTrusted(comptime .wrap("loading"), .{}, page); try page._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" }); } // Dispatch loadingdone event if (page._event_manager.hasDirectListeners(target, "loadingdone", null)) { const event = try Event.initTrusted(comptime .wrap("loadingdone"), .{}, page); try page._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" }); } return page.js.local.?.resolvePromise({}); } // add(fontFace) - no-op; headless browser does not track loaded fonts. pub fn add(self: *FontFaceSet, _: *FontFace) *FontFaceSet { return self; } pub const JsApi = struct { pub const bridge = js.Bridge(FontFaceSet); pub const Meta = struct { pub const name = "FontFaceSet"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(FontFaceSet.deinit); }; pub const size = bridge.property(0, .{ .template = false, .readonly = true }); pub const status = bridge.property("loaded", .{ .template = false, .readonly = true }); pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{}); pub const check = bridge.function(FontFaceSet.check, .{}); pub const load = bridge.function(FontFaceSet.load, .{}); pub const add = bridge.function(FontFaceSet.add, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: FontFaceSet" { try testing.htmlRunner("css/font_face_set.html", .{}); } ================================================ FILE: src/browser/webapi/css/MediaQueryList.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. // zlint-disable unused-decls const std = @import("std"); const js = @import("../../js/js.zig"); const EventTarget = @import("../EventTarget.zig"); const MediaQueryList = @This(); _proto: *EventTarget, _media: []const u8, pub fn deinit(self: *MediaQueryList) void { _ = self; } pub fn asEventTarget(self: *MediaQueryList) *EventTarget { return self._proto; } pub fn getMedia(self: *const MediaQueryList) []const u8 { return self._media; } pub fn addListener(_: *const MediaQueryList, _: js.Function) void {} pub fn removeListener(_: *const MediaQueryList, _: js.Function) void {} pub const JsApi = struct { pub const bridge = js.Bridge(MediaQueryList); pub const Meta = struct { pub const name = "MediaQueryList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const media = bridge.accessor(MediaQueryList.getMedia, null, .{}); pub const matches = bridge.property(false, .{ .template = false, .readonly = true }); pub const addListener = bridge.function(MediaQueryList.addListener, .{ .noop = true }); pub const removeListener = bridge.function(MediaQueryList.removeListener, .{ .noop = true }); }; const testing = @import("../../../testing.zig"); test "WebApi: MediaQueryList" { try testing.htmlRunner("css/media_query_list.html", .{}); } ================================================ FILE: src/browser/webapi/css/StyleSheetList.zig ================================================ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CSSStyleSheet = @import("CSSStyleSheet.zig"); const StyleSheetList = @This(); _sheets: []*CSSStyleSheet = &.{}, pub fn init(page: *Page) !*StyleSheetList { return page._factory.create(StyleSheetList{}); } pub fn length(self: *const StyleSheetList) u32 { return @intCast(self._sheets.len); } pub fn item(self: *const StyleSheetList, index: usize) ?*CSSStyleSheet { if (index >= self._sheets.len) return null; return self._sheets[index]; } pub const JsApi = struct { pub const bridge = js.Bridge(StyleSheetList); pub const Meta = struct { pub const name = "StyleSheetList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const length = bridge.accessor(StyleSheetList.length, null, .{}); pub const @"[]" = bridge.indexed(StyleSheetList.item, null, .{ .null_as_undefined = true }); }; ================================================ FILE: src/browser/webapi/element/Attribute.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const GenericIterator = @import("../collections/iterator.zig").Entry; const Page = @import("../../Page.zig"); const String = @import("../../../string.zig").String; const IS_DEBUG = @import("builtin").mode == .Debug; pub fn registerTypes() []const type { return &.{ Attribute, NamedNodeMap, NamedNodeMap.Iterator, }; } pub const Attribute = @This(); _proto: *Node, _name: String, _value: String, _element: ?*Element, pub fn format(self: *const Attribute, writer: *std.Io.Writer) !void { return formatAttribute(self._name, self._value, writer); } pub fn getName(self: *const Attribute) String { return self._name; } pub fn getValue(self: *const Attribute) String { return self._value; } pub fn setValue(self: *Attribute, data_: ?String, page: *Page) !void { const data = data_ orelse String.empty; const el = self._element orelse { self._value = try data.dupe(page.arena); return; }; // this takes ownership of the data try el.setAttribute(self._name, data, page); // not the most efficient, but we don't expect this to be called often self._value = (try el.getAttribute(self._name, page)) orelse String.empty; } pub fn getNamespaceURI(_: *const Attribute) ?[]const u8 { return null; } pub fn getOwnerElement(self: *const Attribute) ?*Element { return self._element; } pub fn isEqualNode(self: *const Attribute, other: *const Attribute) bool { return self.getName().eql(other.getName()) and self.getValue().eql(other.getValue()); } pub fn clone(self: *const Attribute, page: *Page) !*Attribute { return page._factory.node(Attribute{ ._proto = undefined, ._element = self._element, ._name = self._name, ._value = self._value, }); } pub const JsApi = struct { pub const bridge = js.Bridge(Attribute); pub const Meta = struct { pub const name = "Attr"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; }; pub const name = bridge.accessor(Attribute.getName, null, .{}); pub const localName = bridge.accessor(Attribute.getName, null, .{}); pub const value = bridge.accessor(Attribute.getValue, Attribute.setValue, .{}); pub const namespaceURI = bridge.accessor(Attribute.getNamespaceURI, null, .{}); pub const ownerElement = bridge.accessor(Attribute.getOwnerElement, null, .{}); }; // This is what an Element references. It isn't exposed to JavaScript. In // JavaScript, the element attribute list (el.attributes) is the NamedNodeMap // which exposes Attributes. It isn't ideal that we have both. // NamedNodeMap and Attribute are relatively fat and awkward to use. You can // imagine a page will have tens of thousands of attributes, and it's very likely // that page will _never_ load a single Attribute. It might get a string value // from a string key, but it won't load the full Attribute. And, even if it does, // it will almost certainly load realtively few. // The main issue with Attribute is that it's a full Node -> EventTarget. It's // _huge_ for something that's essentially just name=>value. // That said, we need identity. el.getAttributeNode("id") should return the same // Attribute value (the same JSValue) when called multiple time, and that gets // more important when you look at the [hardly every used] el.removeAttributeNode // and setAttributeNode. // So, we maintain a lookup, page._attribute_lookup, to serve as an identity map // from our internal Entry to a proper Attribute. This is lazily populated // whenever an Attribute is created. Why not just have an ?*Attribute field // in our Entry? Because that would require an extra 8 bytes for every single // attribute in the DOM, and, again, we expect that to almost always be null. pub const List = struct { normalize: bool, /// Length of items in `_list`. Not usize to increase memory usage. /// Honestly, this is more than enough. _len: u32 = 0, _list: std.DoublyLinkedList = .{}, pub fn isEmpty(self: *const List) bool { return self._list.first == null; } pub fn get(self: *const List, name: String, page: *Page) !?String { const entry = (try self.getEntry(name, page)) orelse return null; return entry._value; } pub inline fn length(self: *const List) usize { return self._len; } /// Compares 2 attribute lists for equality. pub fn eql(self: *List, other: *List) bool { if (self.length() != other.length()) { return false; } var iter = self.iterator(); search: while (iter.next()) |attr| { // Iterate over all `other` attributes. var other_iter = other.iterator(); while (other_iter.next()) |other_attr| { if (attr.eql(other_attr)) { continue :search; // Found match. } } // Iterated over all `other` and not match. return false; } return true; } // meant for internal usage, where the name is known to be properly cased pub fn getSafe(self: *const List, name: String) ?[]const u8 { const entry = self.getEntryWithNormalizedName(name) orelse return null; return entry._value.str(); } // meant for internal usage, where the name is known to be properly cased pub fn hasSafe(self: *const List, name: String) bool { return self.getEntryWithNormalizedName(name) != null; } pub fn getAttribute(self: *const List, name: String, element: ?*Element, page: *Page) !?*Attribute { const entry = (try self.getEntry(name, page)) orelse return null; const gop = try page._attribute_lookup.getOrPut(page.arena, @intFromPtr(entry)); if (gop.found_existing) { return gop.value_ptr.*; } const attribute = try entry.toAttribute(element, page); gop.value_ptr.* = attribute; return attribute; } pub fn put(self: *List, name: String, value: String, element: *Element, page: *Page) !*Entry { const result = try self.getEntryAndNormalizedName(name, page); return self._put(result, value, element, page); } pub fn putSafe(self: *List, name: String, value: String, element: *Element, page: *Page) !*Entry { const entry = self.getEntryWithNormalizedName(name); return self._put(.{ .entry = entry, .normalized = name }, value, element, page); } fn _put(self: *List, result: NormalizeAndEntry, value: String, element: *Element, page: *Page) !*Entry { const is_id = shouldAddToIdMap(result.normalized, element); var entry: *Entry = undefined; var old_value: ?String = null; if (result.entry) |e| { old_value = try e._value.dupe(page.call_arena); if (is_id) { page.removeElementId(element, e._value.str()); } e._value = try value.dupe(page.arena); entry = e; } else { entry = try page._factory.create(Entry{ ._node = .{}, ._name = try result.normalized.dupe(page.arena), ._value = try value.dupe(page.arena), }); self._list.append(&entry._node); self._len += 1; } if (is_id) { const parent = element.asNode()._parent orelse { return entry; }; try page.addElementId(parent, element, entry._value.str()); } page.domChanged(); page.attributeChange(element, result.normalized, entry._value, old_value); return entry; } // Optimized for cloning. We know `name` is already normalized. We know there isn't duplicates. // We know the Element is detatched (and thus, don't need to check for `id`). pub fn putForCloned(self: *List, name: []const u8, value: []const u8, page: *Page) !void { const entry = try page._factory.create(Entry{ ._node = .{}, ._name = try String.init(page.arena, name, .{}), ._value = try String.init(page.arena, value, .{}), }); self._list.append(&entry._node); self._len += 1; } // not efficient, won't be called often (if ever!) pub fn putAttribute(self: *List, attribute: *Attribute, element: *Element, page: *Page) !?*Attribute { // we expect our caller to make sure this is true if (comptime IS_DEBUG) { std.debug.assert(attribute._element == null); } const existing_attribute = try self.getAttribute(attribute._name, element, page); if (existing_attribute) |ea| { try self.delete(ea._name, element, page); } const entry = try self.put(attribute._name, attribute._value, element, page); attribute._element = element; try page._attribute_lookup.put(page.arena, @intFromPtr(entry), attribute); return existing_attribute; } // called form our parser, names already lower-cased pub fn putNew(self: *List, name: []const u8, value: []const u8, page: *Page) !void { if (try self.getEntry(.wrap(name), page) != null) { // When parsing, if there are dupicate names, it isn't valid, and // the first is kept return; } const entry = try page._factory.create(Entry{ ._node = .{}, ._name = try String.init(page.arena, name, .{}), ._value = try String.init(page.arena, value, .{}), }); self._list.append(&entry._node); self._len += 1; } pub fn delete(self: *List, name: String, element: *Element, page: *Page) !void { const result = try self.getEntryAndNormalizedName(name, page); const entry = result.entry orelse return; const is_id = shouldAddToIdMap(result.normalized, element); const old_value = entry._value; if (is_id) { page.removeElementId(element, entry._value.str()); } page.domChanged(); page.attributeRemove(element, result.normalized, old_value); _ = page._attribute_lookup.remove(@intFromPtr(entry)); self._list.remove(&entry._node); self._len -= 1; page._factory.destroy(entry); } pub fn getNames(self: *const List, page: *Page) ![][]const u8 { var arr: std.ArrayList([]const u8) = .empty; var node = self._list.first; while (node) |n| { try arr.append(page.call_arena, Entry.fromNode(n)._name.str()); node = n.next; } return arr.items; } pub fn iterator(self: *List) InnerIterator { return .{ ._node = self._list.first }; } fn getEntry(self: *const List, name: String, page: *Page) !?*Entry { const result = try self.getEntryAndNormalizedName(name, page); return result.entry; } // Dangerous, the returned normalized name is only valid until someone // else uses pages.buf. const NormalizeAndEntry = struct { entry: ?*Entry, normalized: String, }; fn getEntryAndNormalizedName(self: *const List, name: String, page: *Page) !NormalizeAndEntry { const normalized = if (self.normalize) try normalizeNameForLookup(name, page) else name; return .{ .normalized = normalized, .entry = self.getEntryWithNormalizedName(normalized), }; } fn getEntryWithNormalizedName(self: *const List, name: String) ?*Entry { var node = self._list.first; while (node) |n| { var e = Entry.fromNode(n); if (e._name.eql(name)) { return e; } node = n.next; } return null; } pub const Entry = struct { _name: String, _value: String, _node: std.DoublyLinkedList.Node, fn fromNode(n: *std.DoublyLinkedList.Node) *Entry { return @alignCast(@fieldParentPtr("_node", n)); } /// Returns true if 2 entries are equal. /// This doesn't compare `_node` fields. pub fn eql(self: *const Entry, other: *const Entry) bool { return self._name.eql(other._name) and self._value.eql(other._value); } pub fn format(self: *const Entry, writer: *std.Io.Writer) !void { return formatAttribute(self._name, self._value, writer); } pub fn toAttribute(self: *const Entry, element: ?*Element, page: *Page) !*Attribute { return page._factory.node(Attribute{ ._proto = undefined, ._element = element, // Cannot directly reference self._name.str() and self._value.str() // This attribute can outlive the list entry (the node can be // removed from the element's attribute, but still exist in the DOM) ._name = try self._name.dupe(page.arena), ._value = try self._value.dupe(page.arena), }); } }; }; fn shouldAddToIdMap(normalized_name: String, element: *Element) bool { if (!normalized_name.eql(comptime .wrap("id"))) { return false; } const node = element.asNode(); // Shadow tree elements are always added to their shadow root's map if (node.isInShadowTree()) { return true; } // Document tree elements only when connected return node.isConnected(); } pub fn validateAttributeName(name: String) !void { const name_str = name.str(); if (name_str.len == 0) { return error.InvalidCharacterError; } const first = name_str[0]; if ((first >= '0' and first <= '9') or first == '-' or first == '.') { return error.InvalidCharacterError; } for (name_str) |c| { if (c == 0 or c == '/' or c == '=' or c == '>' or std.ascii.isWhitespace(c)) { return error.InvalidCharacterError; } const is_valid = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '_' or c == '-' or c == '.' or c == ':'; if (!is_valid) { return error.InvalidCharacterError; } } } pub fn normalizeNameForLookup(name: String, page: *Page) !String { if (!needsLowerCasing(name.str())) { return name; } const normalized = if (name.len < page.buf.len) std.ascii.lowerString(&page.buf, name.str()) else try std.ascii.allocLowerString(page.call_arena, name.str()); return .wrap(normalized); } fn needsLowerCasing(name: []const u8) bool { var remaining = name; if (comptime std.simd.suggestVectorLength(u8)) |vector_len| { while (remaining.len > vector_len) { const chunk: @Vector(vector_len, u8) = remaining[0..vector_len].*; if (@reduce(.Min, chunk) <= 'Z') { return true; } remaining = remaining[vector_len..]; } } for (remaining) |b| { if (std.ascii.isUpper(b)) { return true; } } return false; } pub const NamedNodeMap = struct { _list: *List, // Whenever the NamedNodeMap creates an Attribute, it needs to provide the // "ownerElement". _element: *Element, pub fn length(self: *const NamedNodeMap) u32 { return @intCast(self._list._list.len()); } pub fn getAtIndex(self: *const NamedNodeMap, index: usize, page: *Page) !?*Attribute { var i: usize = 0; var node = self._list._list.first; while (node) |n| { if (i == index) { var entry = List.Entry.fromNode(n); const gop = try page._attribute_lookup.getOrPut(page.arena, @intFromPtr(entry)); if (gop.found_existing) { return gop.value_ptr.*; } const attribute = try entry.toAttribute(self._element, page); gop.value_ptr.* = attribute; return attribute; } node = n.next; i += 1; } return null; } pub fn getByName(self: *const NamedNodeMap, name: String, page: *Page) !?*Attribute { return self._list.getAttribute(name, self._element, page); } pub fn set(self: *const NamedNodeMap, attribute: *Attribute, page: *Page) !?*Attribute { attribute._element = null; // just a requirement of list.putAttribute, it'll re-set it. return self._list.putAttribute(attribute, self._element, page); } pub fn removeByName(self: *const NamedNodeMap, name: String, page: *Page) !?*Attribute { // this 2-step process (get then delete) isn't efficient. But we don't // expect this to be called often, and this lets us keep delete straightforward. const attr = (try self.getByName(name, page)) orelse return null; try self._list.delete(name, self._element, page); return attr; } pub fn iterator(self: *const NamedNodeMap, page: *Page) !*Iterator { return .init(.{ .list = self }, page); } pub const Iterator = GenericIterator(struct { index: usize = 0, list: *const NamedNodeMap, pub fn next(self: *@This(), page: *Page) !?*Attribute { const index = self.index; self.index = index + 1; return self.list.getAtIndex(index, page); } }, null); pub const JsApi = struct { pub const bridge = js.Bridge(NamedNodeMap); pub const Meta = struct { pub const name = "NamedNodeMap"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const length = bridge.accessor(NamedNodeMap.length, null, .{}); pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, .{ .null_as_undefined = true }); pub const getNamedItem = bridge.function(NamedNodeMap.getByName, .{}); pub const setNamedItem = bridge.function(NamedNodeMap.set, .{}); pub const removeNamedItem = bridge.function(NamedNodeMap.removeByName, .{}); pub const item = bridge.function(_item, .{}); fn _item(self: *const NamedNodeMap, index: i32, page: *Page) !?*Attribute { // the bridge.indexed handles this, so if we want // list.item(-2) to return the same as list[-2] we need to // 1 - take an i32 for the index // 2 - return null if it's < 0 if (index < 0) { return null; } return self.getAtIndex(@intCast(index), page); } pub const symbol_iterator = bridge.iterator(NamedNodeMap.iterator, .{}); }; }; // Not meant to be exposed. The "public" iterator is a NamedNodeMap, and it's a // bit awkward. Having this for more straightforward key=>value is useful for // the few internal places we need to iterate through the attributes (e.g. dump) pub const InnerIterator = struct { _node: ?*std.DoublyLinkedList.Node = null, pub fn next(self: *InnerIterator) ?*List.Entry { const node = self._node orelse return null; self._node = node.next; return List.Entry.fromNode(node); } }; fn formatAttribute(name: String, value_: String, writer: *std.Io.Writer) !void { try writer.writeAll(name.str()); // Boolean attributes with empty values are serialized without a value const value = value_.str(); if (value.len == 0 and boolean_attributes_lookup.has(name.str())) { return; } try writer.writeByte('='); if (value.len == 0) { return writer.writeAll("\"\""); } try writer.writeByte('"'); const offset = std.mem.indexOfAny(u8, value, "`' &\"<>=") orelse { try writer.writeAll(value); return writer.writeByte('"'); }; try writeEscapedAttributeValue(value, offset, writer); return writer.writeByte('"'); } const boolean_attributes = [_][]const u8{ "checked", "disabled", "required", "readonly", "multiple", "selected", "autofocus", "autoplay", "controls", "loop", "muted", "hidden", "async", "defer", "novalidate", "formnovalidate", "ismap", "reversed", "default", "open", }; const boolean_attributes_lookup = std.StaticStringMap(void).initComptime(blk: { var entries: [boolean_attributes.len]struct { []const u8, void } = undefined; for (boolean_attributes, 0..) |attr, i| { entries[i] = .{ attr, {} }; } break :blk entries; }); fn writeEscapedAttributeValue(value: []const u8, first_offset: usize, writer: *std.Io.Writer) !void { // Write everything before the first special character try writer.writeAll(value[0..first_offset]); try writer.writeAll(switch (value[first_offset]) { '&' => "&", '"' => """, '<' => "<", '>' => ">", '=' => "=", ' ' => " ", '`' => "`", '\'' => "'", else => unreachable, }); var remaining = value[first_offset + 1 ..]; while (std.mem.indexOfAny(u8, remaining, "&\"<>")) |offset| { try writer.writeAll(remaining[0..offset]); try writer.writeAll(switch (remaining[offset]) { '&' => "&", '"' => """, '<' => "<", '>' => ">", else => unreachable, }); remaining = remaining[offset + 1 ..]; } if (remaining.len > 0) { try writer.writeAll(remaining); } } ================================================ FILE: src/browser/webapi/element/DOMStringMap.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Element = @import("../Element.zig"); const Page = @import("../../Page.zig"); const String = @import("../../../string.zig").String; const Allocator = std.mem.Allocator; const DOMStringMap = @This(); _element: *Element, fn getProperty(self: *DOMStringMap, name: String, page: *Page) !?String { const attr_name = try camelToKebab(page.call_arena, name); return try self._element.getAttribute(attr_name, page); } fn setProperty(self: *DOMStringMap, name: String, value: String, page: *Page) !void { const attr_name = try camelToKebab(page.call_arena, name); return self._element.setAttributeSafe(attr_name, value, page); } fn deleteProperty(self: *DOMStringMap, name: String, page: *Page) !void { const attr_name = try camelToKebab(page.call_arena, name); try self._element.removeAttribute(attr_name, page); } // fooBar -> data-foo-bar (with SSO optimization for short strings) fn camelToKebab(arena: Allocator, camel: String) !String { const camel_str = camel.str(); // Calculate output length var output_len: usize = 5; // "data-" for (camel_str, 0..) |c, i| { output_len += 1; if (std.ascii.isUpper(c) and i > 0) output_len += 1; // extra char for '-' } if (output_len <= 12) { // SSO path - no allocation! var content: [12]u8 = @splat(0); @memcpy(content[0..5], "data-"); var idx: usize = 5; for (camel_str, 0..) |c, i| { if (std.ascii.isUpper(c)) { if (i > 0) { content[idx] = '-'; idx += 1; } content[idx] = std.ascii.toLower(c); } else { content[idx] = c; } idx += 1; } return .{ .len = @intCast(output_len), .payload = .{ .content = content } }; } // Fallback: allocate for longer strings var result: std.ArrayList(u8) = .empty; try result.ensureTotalCapacity(arena, output_len); result.appendSliceAssumeCapacity("data-"); for (camel_str, 0..) |c, i| { if (std.ascii.isUpper(c)) { if (i > 0) { result.appendAssumeCapacity('-'); } result.appendAssumeCapacity(std.ascii.toLower(c)); } else { result.appendAssumeCapacity(c); } } return try String.init(arena, result.items, .{}); } // data-foo-bar -> fooBar fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 { if (!std.mem.startsWith(u8, kebab, "data-")) { return null; } const data_part = kebab[5..]; // Skip "data-" if (data_part.len == 0) { return null; } var result: std.ArrayList(u8) = .empty; try result.ensureTotalCapacity(arena, data_part.len); var capitalize_next = false; for (data_part) |c| { if (c == '-') { capitalize_next = true; } else if (capitalize_next) { result.appendAssumeCapacity(std.ascii.toUpper(c)); capitalize_next = false; } else { result.appendAssumeCapacity(c); } } return result.items; } pub const JsApi = struct { pub const bridge = js.Bridge(DOMStringMap); pub const Meta = struct { pub const name = "DOMStringMap"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const @"[]" = bridge.namedIndexed(getProperty, setProperty, deleteProperty, .{ .null_as_undefined = true }); }; ================================================ FILE: src/browser/webapi/element/Html.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const reflect = @import("../../reflect.zig"); const log = @import("../../../log.zig"); const global_event_handlers = @import("../global_event_handlers.zig"); const GlobalEventHandlersLookup = global_event_handlers.Lookup; const GlobalEventHandler = global_event_handlers.Handler; const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); pub const Anchor = @import("html/Anchor.zig"); pub const Area = @import("html/Area.zig"); pub const Base = @import("html/Base.zig"); pub const Body = @import("html/Body.zig"); pub const BR = @import("html/BR.zig"); pub const Button = @import("html/Button.zig"); pub const Canvas = @import("html/Canvas.zig"); pub const Custom = @import("html/Custom.zig"); pub const Data = @import("html/Data.zig"); pub const DataList = @import("html/DataList.zig"); pub const Details = @import("html/Details.zig"); pub const Dialog = @import("html/Dialog.zig"); pub const Directory = @import("html/Directory.zig"); pub const Div = @import("html/Div.zig"); pub const DList = @import("html/DList.zig"); pub const Embed = @import("html/Embed.zig"); pub const FieldSet = @import("html/FieldSet.zig"); pub const Font = @import("html/Font.zig"); pub const Form = @import("html/Form.zig"); pub const Generic = @import("html/Generic.zig"); pub const Head = @import("html/Head.zig"); pub const Heading = @import("html/Heading.zig"); pub const HR = @import("html/HR.zig"); pub const Html = @import("html/Html.zig"); pub const IFrame = @import("html/IFrame.zig"); pub const Image = @import("html/Image.zig"); pub const Input = @import("html/Input.zig"); pub const Label = @import("html/Label.zig"); pub const Legend = @import("html/Legend.zig"); pub const LI = @import("html/LI.zig"); pub const Link = @import("html/Link.zig"); pub const Map = @import("html/Map.zig"); pub const Media = @import("html/Media.zig"); pub const Meta = @import("html/Meta.zig"); pub const Meter = @import("html/Meter.zig"); pub const Mod = @import("html/Mod.zig"); pub const Object = @import("html/Object.zig"); pub const OL = @import("html/OL.zig"); pub const OptGroup = @import("html/OptGroup.zig"); pub const Option = @import("html/Option.zig"); pub const Output = @import("html/Output.zig"); pub const Paragraph = @import("html/Paragraph.zig"); pub const Picture = @import("html/Picture.zig"); pub const Param = @import("html/Param.zig"); pub const Pre = @import("html/Pre.zig"); pub const Progress = @import("html/Progress.zig"); pub const Quote = @import("html/Quote.zig"); pub const Script = @import("html/Script.zig"); pub const Select = @import("html/Select.zig"); pub const Slot = @import("html/Slot.zig"); pub const Source = @import("html/Source.zig"); pub const Span = @import("html/Span.zig"); pub const Style = @import("html/Style.zig"); pub const Table = @import("html/Table.zig"); pub const TableCaption = @import("html/TableCaption.zig"); pub const TableCell = @import("html/TableCell.zig"); pub const TableCol = @import("html/TableCol.zig"); pub const TableRow = @import("html/TableRow.zig"); pub const TableSection = @import("html/TableSection.zig"); pub const Template = @import("html/Template.zig"); pub const TextArea = @import("html/TextArea.zig"); pub const Time = @import("html/Time.zig"); pub const Title = @import("html/Title.zig"); pub const Track = @import("html/Track.zig"); pub const UL = @import("html/UL.zig"); pub const Unknown = @import("html/Unknown.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const HtmlElement = @This(); _type: Type, _proto: *Element, // Special constructor for custom elements pub fn construct(page: *Page) !*Element { const node = page._upgrading_element orelse return error.IllegalConstructor; return node.is(Element) orelse return error.IllegalConstructor; } pub const Type = union(enum) { anchor: *Anchor, area: *Area, base: *Base, body: *Body, br: *BR, button: *Button, canvas: *Canvas, custom: *Custom, data: *Data, datalist: *DataList, details: *Details, dialog: *Dialog, directory: *Directory, div: *Div, dl: *DList, embed: *Embed, fieldset: *FieldSet, font: *Font, form: *Form, generic: *Generic, heading: *Heading, head: *Head, html: *Html, hr: *HR, img: *Image, iframe: *IFrame, input: *Input, label: *Label, legend: *Legend, li: *LI, link: *Link, map: *Map, media: *Media, meta: *Meta, meter: *Meter, mod: *Mod, object: *Object, ol: *OL, optgroup: *OptGroup, option: *Option, output: *Output, p: *Paragraph, picture: *Picture, param: *Param, pre: *Pre, progress: *Progress, quote: *Quote, script: *Script, select: *Select, slot: *Slot, source: *Source, span: *Span, style: *Style, table: *Table, table_caption: *TableCaption, table_cell: *TableCell, table_col: *TableCol, table_row: *TableRow, table_section: *TableSection, template: *Template, textarea: *TextArea, time: *Time, title: *Title, track: *Track, ul: *UL, unknown: *Unknown, }; pub fn is(self: *HtmlElement, comptime T: type) ?*T { inline for (@typeInfo(Type).@"union".fields) |f| { if (@field(Type, f.name) == self._type) { if (f.type == T) { return &@field(self._type, f.name); } if (f.type == *T) { return @field(self._type, f.name); } } } return null; } pub fn asElement(self: *HtmlElement) *Element { return self._proto; } pub fn asNode(self: *HtmlElement) *Node { return self._proto._proto; } pub fn asEventTarget(self: *HtmlElement) *@import("../EventTarget.zig") { return self._proto._proto._proto; } // innerText represents the **rendered** text content of a node and its // descendants. pub fn getInnerText(self: *HtmlElement, writer: *std.Io.Writer) !void { var state = innerTextState{}; return try self._getInnerText(writer, &state); } const innerTextState = struct { pre_w: bool = false, trim_left: bool = true, }; fn _getInnerText(self: *HtmlElement, writer: *std.Io.Writer, state: *innerTextState) !void { var it = self.asElement().asNode().childrenIterator(); while (it.next()) |child| { switch (child._type) { .element => |e| switch (e._type) { .html => |he| switch (he._type) { .br => { try writer.writeByte('\n'); state.pre_w = false; // prevent a next pre space. state.trim_left = true; }, .script, .style, .template => { state.pre_w = false; // prevent a next pre space. state.trim_left = true; }, else => try he._getInnerText(writer, state), // TODO check if elt is hidden. }, .svg => {}, }, .cdata => |c| switch (c._type) { .comment => { state.pre_w = false; // prevent a next pre space. state.trim_left = true; }, .text => { if (state.pre_w) try writer.writeByte(' '); state.pre_w = try c.render(writer, .{ .trim_left = state.trim_left }); // if we had a pre space, trim left next one. state.trim_left = state.pre_w; }, // CDATA sections should not be used within HTML. They are // considered comments and are not displayed. .cdata_section => {}, // Processing instructions are not displayed in innerText .processing_instruction => {}, }, .document => {}, .document_type => {}, .document_fragment => {}, .attribute => |attr| try writer.writeAll(attr._value.str()), } } } pub fn setInnerText(self: *HtmlElement, text: []const u8, page: *Page) !void { const parent = self.asElement().asNode(); // Remove all existing children page.domChanged(); var it = parent.childrenIterator(); while (it.next()) |child| { page.removeNode(parent, child, .{ .will_be_reconnected = false }); } // Fast path: skip if text is empty if (text.len == 0) { return; } // Create and append text node const text_node = try page.createTextNode(text); try page.appendNode(parent, text_node, .{ .child_already_connected = false }); } pub fn insertAdjacentHTML( self: *HtmlElement, position: []const u8, html: []const u8, page: *Page, ) !void { // Create a new HTMLDocument. const doc = try page._factory.document(@import("../HTMLDocument.zig"){ ._proto = undefined, }); const doc_node = doc.asNode(); const arena = try page.getArena(.{ .debug = "HTML.insertAdjacentHTML" }); defer page.releaseArena(arena); const Parser = @import("../../parser/Parser.zig"); var parser = Parser.init(arena, doc_node, page); parser.parse(html); // Check if there's parsing error. if (parser.err) |_| { return error.Invalid; } // The parser wraps content in a document structure: // - Typical: <html><head>...</head><body>...</body></html> // - Head-only: <html><head><meta></head></html> (no body) // - Empty/comments: May have no <html> element at all const html_node = doc_node.firstChild() orelse return; const target_node, const prev_node = try self.asElement().asNode().findAdjacentNodes(position); // Iterate through all children of <html> (typically <head> and/or <body>) // and insert their children (not the containers themselves) into the target. // This handles both body content AND head-only elements like <meta>, <title>, etc. var html_children = html_node.childrenIterator(); while (html_children.next()) |container| { var iter = container.childrenIterator(); while (iter.next()) |child_node| { _ = try target_node.insertBefore(child_node, prev_node, page); } } } pub fn click(self: *HtmlElement, page: *Page) !void { switch (self._type) { inline .button, .input, .textarea, .select => |i| { if (i.getDisabled()) { return; } }, else => {}, } const event = (try @import("../event/MouseEvent.zig").init("click", .{ .bubbles = true, .cancelable = true, .composed = true, .clientX = 0, .clientY = 0, }, page)).asEvent(); try page._event_manager.dispatch(self.asEventTarget(), event); } // TODO: Per spec, hidden is a tristate: true | false | "until-found". // We only support boolean for now; "until-found" would need bridge union support. pub fn getHidden(self: *HtmlElement) bool { return self.asElement().getAttributeSafe(comptime .wrap("hidden")) != null; } pub fn setHidden(self: *HtmlElement, hidden: bool, page: *Page) !void { if (hidden) { try self.asElement().setAttributeSafe(comptime .wrap("hidden"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("hidden"), page); } } pub fn getTabIndex(self: *HtmlElement) i32 { const attr = self.asElement().getAttributeSafe(comptime .wrap("tabindex")) orelse { // Per spec, interactive/focusable elements default to 0 when tabindex is absent return switch (self._type) { .anchor, .area, .button, .input, .select, .textarea, .iframe => 0, else => -1, }; }; return std.fmt.parseInt(i32, attr, 10) catch -1; } pub fn setTabIndex(self: *HtmlElement, value: i32, page: *Page) !void { var buf: [12]u8 = undefined; const str = std.fmt.bufPrint(&buf, "{d}", .{value}) catch unreachable; try self.asElement().setAttributeSafe(comptime .wrap("tabindex"), .wrap(str), page); } pub fn getAttributeFunction( self: *HtmlElement, listener_type: GlobalEventHandler, page: *Page, ) !?js.Function.Global { const element = self.asElement(); if (page._event_target_attr_listeners.get(.{ .target = element.asEventTarget(), .handler = listener_type })) |cached| { return cached; } const attr = element.getAttributeSafe(.wrap(@tagName(listener_type))) orelse return null; const function = page.js.stringToPersistedFunction(attr, &.{"event"}, &.{}) catch |err| { // Not a valid expression; log this to find out if its something we should be supporting. log.warn(.js, "Html.getAttributeFunction", .{ .expression = attr, .err = err, }); return null; }; try self.setAttributeListener(listener_type, function, page); return function; } pub fn hasAttributeFunction(self: *HtmlElement, listener_type: GlobalEventHandler, page: *const Page) bool { return page._event_target_attr_listeners.contains(.{ .target = self.asEventTarget(), .handler = listener_type }); } fn setAttributeListener( self: *Element.Html, listener_type: GlobalEventHandler, listener_callback: ?js.Function.Global, page: *Page, ) !void { if (comptime IS_DEBUG) { log.debug(.event, "Html.setAttributeListener", .{ .type = std.meta.activeTag(self._type), .listener_type = listener_type, }); } if (listener_callback) |cb| { try page._event_target_attr_listeners.put(page.arena, .{ .target = self.asEventTarget(), .handler = listener_type, }, cb); return; } // The listener is null, remove existing listener. _ = page._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = listener_type, }); } pub fn setOnAbort(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onabort, callback, page); } pub fn getOnAbort(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onabort, page); } pub fn setOnAnimationCancel(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onanimationcancel, callback, page); } pub fn getOnAnimationCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onanimationcancel, page); } pub fn setOnAnimationEnd(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onanimationend, callback, page); } pub fn getOnAnimationEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onanimationend, page); } pub fn setOnAnimationIteration(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onanimationiteration, callback, page); } pub fn getOnAnimationIteration(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onanimationiteration, page); } pub fn setOnAnimationStart(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onanimationstart, callback, page); } pub fn getOnAnimationStart(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onanimationstart, page); } pub fn setOnAuxClick(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onauxclick, callback, page); } pub fn getOnAuxClick(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onauxclick, page); } pub fn setOnBeforeInput(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onbeforeinput, callback, page); } pub fn getOnBeforeInput(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onbeforeinput, page); } pub fn setOnBeforeMatch(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onbeforematch, callback, page); } pub fn getOnBeforeMatch(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onbeforematch, page); } pub fn setOnBeforeToggle(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onbeforetoggle, callback, page); } pub fn getOnBeforeToggle(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onbeforetoggle, page); } pub fn setOnBlur(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onblur, callback, page); } pub fn getOnBlur(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onblur, page); } pub fn setOnCancel(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncancel, callback, page); } pub fn getOnCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncancel, page); } pub fn setOnCanPlay(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncanplay, callback, page); } pub fn getOnCanPlay(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncanplay, page); } pub fn setOnCanPlayThrough(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncanplaythrough, callback, page); } pub fn getOnCanPlayThrough(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncanplaythrough, page); } pub fn setOnChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onchange, callback, page); } pub fn getOnChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onchange, page); } pub fn setOnClick(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onclick, callback, page); } pub fn getOnClick(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onclick, page); } pub fn setOnClose(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onclose, callback, page); } pub fn getOnClose(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onclose, page); } pub fn setOnCommand(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncommand, callback, page); } pub fn getOnCommand(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncommand, page); } pub fn setOnContentVisibilityAutoStateChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncontentvisibilityautostatechange, callback, page); } pub fn getOnContentVisibilityAutoStateChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncontentvisibilityautostatechange, page); } pub fn setOnContextLost(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncontextlost, callback, page); } pub fn getOnContextLost(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncontextlost, page); } pub fn setOnContextMenu(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncontextmenu, callback, page); } pub fn getOnContextMenu(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncontextmenu, page); } pub fn setOnContextRestored(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncontextrestored, callback, page); } pub fn getOnContextRestored(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncontextrestored, page); } pub fn setOnCopy(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncopy, callback, page); } pub fn getOnCopy(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncopy, page); } pub fn setOnCueChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncuechange, callback, page); } pub fn getOnCueChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncuechange, page); } pub fn setOnCut(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oncut, callback, page); } pub fn getOnCut(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oncut, page); } pub fn setOnDblClick(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondblclick, callback, page); } pub fn getOnDblClick(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondblclick, page); } pub fn setOnDrag(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondrag, callback, page); } pub fn getOnDrag(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondrag, page); } pub fn setOnDragEnd(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondragend, callback, page); } pub fn getOnDragEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondragend, page); } pub fn setOnDragEnter(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondragenter, callback, page); } pub fn getOnDragEnter(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondragenter, page); } pub fn setOnDragExit(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondragexit, callback, page); } pub fn getOnDragExit(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondragexit, page); } pub fn setOnDragLeave(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondragleave, callback, page); } pub fn getOnDragLeave(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondragleave, page); } pub fn setOnDragOver(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondragover, callback, page); } pub fn getOnDragOver(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondragover, page); } pub fn setOnDragStart(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondragstart, callback, page); } pub fn getOnDragStart(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondragstart, page); } pub fn setOnDrop(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondrop, callback, page); } pub fn getOnDrop(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondrop, page); } pub fn setOnDurationChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ondurationchange, callback, page); } pub fn getOnDurationChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ondurationchange, page); } pub fn setOnEmptied(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onemptied, callback, page); } pub fn getOnEmptied(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onemptied, page); } pub fn setOnEnded(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onended, callback, page); } pub fn getOnEnded(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onended, page); } pub fn setOnError(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onerror, callback, page); } pub fn getOnError(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onerror, page); } pub fn setOnFocus(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onfocus, callback, page); } pub fn getOnFocus(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onfocus, page); } pub fn setOnFormData(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onformdata, callback, page); } pub fn getOnFormData(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onformdata, page); } pub fn setOnFullscreenChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onfullscreenchange, callback, page); } pub fn getOnFullscreenChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onfullscreenchange, page); } pub fn setOnFullscreenError(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onfullscreenerror, callback, page); } pub fn getOnFullscreenError(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onfullscreenerror, page); } pub fn setOnGotPointerCapture(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ongotpointercapture, callback, page); } pub fn getOnGotPointerCapture(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ongotpointercapture, page); } pub fn setOnInput(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oninput, callback, page); } pub fn getOnInput(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oninput, page); } pub fn setOnInvalid(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.oninvalid, callback, page); } pub fn getOnInvalid(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.oninvalid, page); } pub fn setOnKeyDown(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onkeydown, callback, page); } pub fn getOnKeyDown(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onkeydown, page); } pub fn setOnKeyPress(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onkeypress, callback, page); } pub fn getOnKeyPress(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onkeypress, page); } pub fn setOnKeyUp(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onkeyup, callback, page); } pub fn getOnKeyUp(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onkeyup, page); } pub fn setOnLoad(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onload, callback, page); } pub fn getOnLoad(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onload, page); } pub fn setOnLoadedData(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onloadeddata, callback, page); } pub fn getOnLoadedData(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onloadeddata, page); } pub fn setOnLoadedMetadata(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onloadedmetadata, callback, page); } pub fn getOnLoadedMetadata(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onloadedmetadata, page); } pub fn setOnLoadStart(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onloadstart, callback, page); } pub fn getOnLoadStart(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onloadstart, page); } pub fn setOnLostPointerCapture(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onlostpointercapture, callback, page); } pub fn getOnLostPointerCapture(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onlostpointercapture, page); } pub fn setOnMouseDown(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onmousedown, callback, page); } pub fn getOnMouseDown(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onmousedown, page); } pub fn setOnMouseMove(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onmousemove, callback, page); } pub fn getOnMouseMove(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onmousemove, page); } pub fn setOnMouseOut(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onmouseout, callback, page); } pub fn getOnMouseOut(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onmouseout, page); } pub fn setOnMouseOver(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onmouseover, callback, page); } pub fn getOnMouseOver(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onmouseover, page); } pub fn setOnMouseUp(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onmouseup, callback, page); } pub fn getOnMouseUp(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onmouseup, page); } pub fn setOnPaste(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpaste, callback, page); } pub fn getOnPaste(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpaste, page); } pub fn setOnPause(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpause, callback, page); } pub fn getOnPause(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpause, page); } pub fn setOnPlay(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onplay, callback, page); } pub fn getOnPlay(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onplay, page); } pub fn setOnPlaying(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onplaying, callback, page); } pub fn getOnPlaying(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onplaying, page); } pub fn setOnPointerCancel(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointercancel, callback, page); } pub fn getOnPointerCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointercancel, page); } pub fn setOnPointerDown(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointerdown, callback, page); } pub fn getOnPointerDown(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointerdown, page); } pub fn setOnPointerEnter(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointerenter, callback, page); } pub fn getOnPointerEnter(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointerenter, page); } pub fn setOnPointerLeave(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointerleave, callback, page); } pub fn getOnPointerLeave(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointerleave, page); } pub fn setOnPointerMove(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointermove, callback, page); } pub fn getOnPointerMove(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointermove, page); } pub fn setOnPointerOut(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointerout, callback, page); } pub fn getOnPointerOut(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointerout, page); } pub fn setOnPointerOver(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointerover, callback, page); } pub fn getOnPointerOver(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointerover, page); } pub fn setOnPointerRawUpdate(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointerrawupdate, callback, page); } pub fn getOnPointerRawUpdate(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointerrawupdate, page); } pub fn setOnPointerUp(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onpointerup, callback, page); } pub fn getOnPointerUp(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onpointerup, page); } pub fn setOnProgress(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onprogress, callback, page); } pub fn getOnProgress(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onprogress, page); } pub fn setOnRateChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onratechange, callback, page); } pub fn getOnRateChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onratechange, page); } pub fn setOnReset(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onreset, callback, page); } pub fn getOnReset(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onreset, page); } pub fn setOnResize(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onresize, callback, page); } pub fn getOnResize(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onresize, page); } pub fn setOnScroll(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onscroll, callback, page); } pub fn getOnScroll(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onscroll, page); } pub fn setOnScrollEnd(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onscrollend, callback, page); } pub fn getOnScrollEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onscrollend, page); } pub fn setOnSecurityPolicyViolation(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onsecuritypolicyviolation, callback, page); } pub fn getOnSecurityPolicyViolation(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onsecuritypolicyviolation, page); } pub fn setOnSeeked(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onseeked, callback, page); } pub fn getOnSeeked(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onseeked, page); } pub fn setOnSeeking(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onseeking, callback, page); } pub fn getOnSeeking(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onseeking, page); } pub fn setOnSelect(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onselect, callback, page); } pub fn getOnSelect(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onselect, page); } pub fn setOnSelectionChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onselectionchange, callback, page); } pub fn getOnSelectionChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onselectionchange, page); } pub fn setOnSelectStart(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onselectstart, callback, page); } pub fn getOnSelectStart(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onselectstart, page); } pub fn setOnSlotChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onslotchange, callback, page); } pub fn getOnSlotChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onslotchange, page); } pub fn setOnStalled(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onstalled, callback, page); } pub fn getOnStalled(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onstalled, page); } pub fn setOnSubmit(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onsubmit, callback, page); } pub fn getOnSubmit(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onsubmit, page); } pub fn setOnSuspend(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onsuspend, callback, page); } pub fn getOnSuspend(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onsuspend, page); } pub fn setOnTimeUpdate(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ontimeupdate, callback, page); } pub fn getOnTimeUpdate(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ontimeupdate, page); } pub fn setOnToggle(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ontoggle, callback, page); } pub fn getOnToggle(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ontoggle, page); } pub fn setOnTransitionCancel(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ontransitioncancel, callback, page); } pub fn getOnTransitionCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ontransitioncancel, page); } pub fn setOnTransitionEnd(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ontransitionend, callback, page); } pub fn getOnTransitionEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ontransitionend, page); } pub fn setOnTransitionRun(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ontransitionrun, callback, page); } pub fn getOnTransitionRun(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ontransitionrun, page); } pub fn setOnTransitionStart(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.ontransitionstart, callback, page); } pub fn getOnTransitionStart(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.ontransitionstart, page); } pub fn setOnVolumeChange(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onvolumechange, callback, page); } pub fn getOnVolumeChange(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onvolumechange, page); } pub fn setOnWaiting(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onwaiting, callback, page); } pub fn getOnWaiting(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onwaiting, page); } pub fn setOnWheel(self: *HtmlElement, callback: ?js.Function.Global, page: *Page) !void { return self.setAttributeListener(.onwheel, callback, page); } pub fn getOnWheel(self: *HtmlElement, page: *Page) !?js.Function.Global { return self.getAttributeFunction(.onwheel, page); } pub const JsApi = struct { pub const bridge = js.Bridge(HtmlElement); pub const Meta = struct { pub const name = "HTMLElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(HtmlElement.construct, .{}); pub const innerText = bridge.accessor(_innerText, HtmlElement.setInnerText, .{}); fn _innerText(self: *HtmlElement, page: *const Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getInnerText(&buf.writer); return buf.written(); } pub const insertAdjacentHTML = bridge.function(HtmlElement.insertAdjacentHTML, .{ .dom_exception = true }); pub const click = bridge.function(HtmlElement.click, .{}); pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{}); pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{}); pub const onabort = bridge.accessor(HtmlElement.getOnAbort, HtmlElement.setOnAbort, .{}); pub const onanimationcancel = bridge.accessor(HtmlElement.getOnAnimationCancel, HtmlElement.setOnAnimationCancel, .{}); pub const onanimationend = bridge.accessor(HtmlElement.getOnAnimationEnd, HtmlElement.setOnAnimationEnd, .{}); pub const onanimationiteration = bridge.accessor(HtmlElement.getOnAnimationIteration, HtmlElement.setOnAnimationIteration, .{}); pub const onanimationstart = bridge.accessor(HtmlElement.getOnAnimationStart, HtmlElement.setOnAnimationStart, .{}); pub const onauxclick = bridge.accessor(HtmlElement.getOnAuxClick, HtmlElement.setOnAuxClick, .{}); pub const onbeforeinput = bridge.accessor(HtmlElement.getOnBeforeInput, HtmlElement.setOnBeforeInput, .{}); pub const onbeforematch = bridge.accessor(HtmlElement.getOnBeforeMatch, HtmlElement.setOnBeforeMatch, .{}); pub const onbeforetoggle = bridge.accessor(HtmlElement.getOnBeforeToggle, HtmlElement.setOnBeforeToggle, .{}); pub const onblur = bridge.accessor(HtmlElement.getOnBlur, HtmlElement.setOnBlur, .{}); pub const oncancel = bridge.accessor(HtmlElement.getOnCancel, HtmlElement.setOnCancel, .{}); pub const oncanplay = bridge.accessor(HtmlElement.getOnCanPlay, HtmlElement.setOnCanPlay, .{}); pub const oncanplaythrough = bridge.accessor(HtmlElement.getOnCanPlayThrough, HtmlElement.setOnCanPlayThrough, .{}); pub const onchange = bridge.accessor(HtmlElement.getOnChange, HtmlElement.setOnChange, .{}); pub const onclick = bridge.accessor(HtmlElement.getOnClick, HtmlElement.setOnClick, .{}); pub const onclose = bridge.accessor(HtmlElement.getOnClose, HtmlElement.setOnClose, .{}); pub const oncommand = bridge.accessor(HtmlElement.getOnCommand, HtmlElement.setOnCommand, .{}); pub const oncontentvisibilityautostatechange = bridge.accessor(HtmlElement.getOnContentVisibilityAutoStateChange, HtmlElement.setOnContentVisibilityAutoStateChange, .{}); pub const oncontextlost = bridge.accessor(HtmlElement.getOnContextLost, HtmlElement.setOnContextLost, .{}); pub const oncontextmenu = bridge.accessor(HtmlElement.getOnContextMenu, HtmlElement.setOnContextMenu, .{}); pub const oncontextrestored = bridge.accessor(HtmlElement.getOnContextRestored, HtmlElement.setOnContextRestored, .{}); pub const oncopy = bridge.accessor(HtmlElement.getOnCopy, HtmlElement.setOnCopy, .{}); pub const oncuechange = bridge.accessor(HtmlElement.getOnCueChange, HtmlElement.setOnCueChange, .{}); pub const oncut = bridge.accessor(HtmlElement.getOnCut, HtmlElement.setOnCut, .{}); pub const ondblclick = bridge.accessor(HtmlElement.getOnDblClick, HtmlElement.setOnDblClick, .{}); pub const ondrag = bridge.accessor(HtmlElement.getOnDrag, HtmlElement.setOnDrag, .{}); pub const ondragend = bridge.accessor(HtmlElement.getOnDragEnd, HtmlElement.setOnDragEnd, .{}); pub const ondragenter = bridge.accessor(HtmlElement.getOnDragEnter, HtmlElement.setOnDragEnter, .{}); pub const ondragexit = bridge.accessor(HtmlElement.getOnDragExit, HtmlElement.setOnDragExit, .{}); pub const ondragleave = bridge.accessor(HtmlElement.getOnDragLeave, HtmlElement.setOnDragLeave, .{}); pub const ondragover = bridge.accessor(HtmlElement.getOnDragOver, HtmlElement.setOnDragOver, .{}); pub const ondragstart = bridge.accessor(HtmlElement.getOnDragStart, HtmlElement.setOnDragStart, .{}); pub const ondrop = bridge.accessor(HtmlElement.getOnDrop, HtmlElement.setOnDrop, .{}); pub const ondurationchange = bridge.accessor(HtmlElement.getOnDurationChange, HtmlElement.setOnDurationChange, .{}); pub const onemptied = bridge.accessor(HtmlElement.getOnEmptied, HtmlElement.setOnEmptied, .{}); pub const onended = bridge.accessor(HtmlElement.getOnEnded, HtmlElement.setOnEnded, .{}); pub const onerror = bridge.accessor(HtmlElement.getOnError, HtmlElement.setOnError, .{}); pub const onfocus = bridge.accessor(HtmlElement.getOnFocus, HtmlElement.setOnFocus, .{}); pub const onformdata = bridge.accessor(HtmlElement.getOnFormData, HtmlElement.setOnFormData, .{}); pub const onfullscreenchange = bridge.accessor(HtmlElement.getOnFullscreenChange, HtmlElement.setOnFullscreenChange, .{}); pub const onfullscreenerror = bridge.accessor(HtmlElement.getOnFullscreenError, HtmlElement.setOnFullscreenError, .{}); pub const ongotpointercapture = bridge.accessor(HtmlElement.getOnGotPointerCapture, HtmlElement.setOnGotPointerCapture, .{}); pub const oninput = bridge.accessor(HtmlElement.getOnInput, HtmlElement.setOnInput, .{}); pub const oninvalid = bridge.accessor(HtmlElement.getOnInvalid, HtmlElement.setOnInvalid, .{}); pub const onkeydown = bridge.accessor(HtmlElement.getOnKeyDown, HtmlElement.setOnKeyDown, .{}); pub const onkeypress = bridge.accessor(HtmlElement.getOnKeyPress, HtmlElement.setOnKeyPress, .{}); pub const onkeyup = bridge.accessor(HtmlElement.getOnKeyUp, HtmlElement.setOnKeyUp, .{}); pub const onload = bridge.accessor(HtmlElement.getOnLoad, HtmlElement.setOnLoad, .{}); pub const onloadeddata = bridge.accessor(HtmlElement.getOnLoadedData, HtmlElement.setOnLoadedData, .{}); pub const onloadedmetadata = bridge.accessor(HtmlElement.getOnLoadedMetadata, HtmlElement.setOnLoadedMetadata, .{}); pub const onloadstart = bridge.accessor(HtmlElement.getOnLoadStart, HtmlElement.setOnLoadStart, .{}); pub const onlostpointercapture = bridge.accessor(HtmlElement.getOnLostPointerCapture, HtmlElement.setOnLostPointerCapture, .{}); pub const onmousedown = bridge.accessor(HtmlElement.getOnMouseDown, HtmlElement.setOnMouseDown, .{}); pub const onmousemove = bridge.accessor(HtmlElement.getOnMouseMove, HtmlElement.setOnMouseMove, .{}); pub const onmouseout = bridge.accessor(HtmlElement.getOnMouseOut, HtmlElement.setOnMouseOut, .{}); pub const onmouseover = bridge.accessor(HtmlElement.getOnMouseOver, HtmlElement.setOnMouseOver, .{}); pub const onmouseup = bridge.accessor(HtmlElement.getOnMouseUp, HtmlElement.setOnMouseUp, .{}); pub const onpaste = bridge.accessor(HtmlElement.getOnPaste, HtmlElement.setOnPaste, .{}); pub const onpause = bridge.accessor(HtmlElement.getOnPause, HtmlElement.setOnPause, .{}); pub const onplay = bridge.accessor(HtmlElement.getOnPlay, HtmlElement.setOnPlay, .{}); pub const onplaying = bridge.accessor(HtmlElement.getOnPlaying, HtmlElement.setOnPlaying, .{}); pub const onpointercancel = bridge.accessor(HtmlElement.getOnPointerCancel, HtmlElement.setOnPointerCancel, .{}); pub const onpointerdown = bridge.accessor(HtmlElement.getOnPointerDown, HtmlElement.setOnPointerDown, .{}); pub const onpointerenter = bridge.accessor(HtmlElement.getOnPointerEnter, HtmlElement.setOnPointerEnter, .{}); pub const onpointerleave = bridge.accessor(HtmlElement.getOnPointerLeave, HtmlElement.setOnPointerLeave, .{}); pub const onpointermove = bridge.accessor(HtmlElement.getOnPointerMove, HtmlElement.setOnPointerMove, .{}); pub const onpointerout = bridge.accessor(HtmlElement.getOnPointerOut, HtmlElement.setOnPointerOut, .{}); pub const onpointerover = bridge.accessor(HtmlElement.getOnPointerOver, HtmlElement.setOnPointerOver, .{}); pub const onpointerrawupdate = bridge.accessor(HtmlElement.getOnPointerRawUpdate, HtmlElement.setOnPointerRawUpdate, .{}); pub const onpointerup = bridge.accessor(HtmlElement.getOnPointerUp, HtmlElement.setOnPointerUp, .{}); pub const onprogress = bridge.accessor(HtmlElement.getOnProgress, HtmlElement.setOnProgress, .{}); pub const onratechange = bridge.accessor(HtmlElement.getOnRateChange, HtmlElement.setOnRateChange, .{}); pub const onreset = bridge.accessor(HtmlElement.getOnReset, HtmlElement.setOnReset, .{}); pub const onresize = bridge.accessor(HtmlElement.getOnResize, HtmlElement.setOnResize, .{}); pub const onscroll = bridge.accessor(HtmlElement.getOnScroll, HtmlElement.setOnScroll, .{}); pub const onscrollend = bridge.accessor(HtmlElement.getOnScrollEnd, HtmlElement.setOnScrollEnd, .{}); pub const onsecuritypolicyviolation = bridge.accessor(HtmlElement.getOnSecurityPolicyViolation, HtmlElement.setOnSecurityPolicyViolation, .{}); pub const onseeked = bridge.accessor(HtmlElement.getOnSeeked, HtmlElement.setOnSeeked, .{}); pub const onseeking = bridge.accessor(HtmlElement.getOnSeeking, HtmlElement.setOnSeeking, .{}); pub const onselect = bridge.accessor(HtmlElement.getOnSelect, HtmlElement.setOnSelect, .{}); pub const onselectionchange = bridge.accessor(HtmlElement.getOnSelectionChange, HtmlElement.setOnSelectionChange, .{}); pub const onselectstart = bridge.accessor(HtmlElement.getOnSelectStart, HtmlElement.setOnSelectStart, .{}); pub const onslotchange = bridge.accessor(HtmlElement.getOnSlotChange, HtmlElement.setOnSlotChange, .{}); pub const onstalled = bridge.accessor(HtmlElement.getOnStalled, HtmlElement.setOnStalled, .{}); pub const onsubmit = bridge.accessor(HtmlElement.getOnSubmit, HtmlElement.setOnSubmit, .{}); pub const onsuspend = bridge.accessor(HtmlElement.getOnSuspend, HtmlElement.setOnSuspend, .{}); pub const ontimeupdate = bridge.accessor(HtmlElement.getOnTimeUpdate, HtmlElement.setOnTimeUpdate, .{}); pub const ontoggle = bridge.accessor(HtmlElement.getOnToggle, HtmlElement.setOnToggle, .{}); pub const ontransitioncancel = bridge.accessor(HtmlElement.getOnTransitionCancel, HtmlElement.setOnTransitionCancel, .{}); pub const ontransitionend = bridge.accessor(HtmlElement.getOnTransitionEnd, HtmlElement.setOnTransitionEnd, .{}); pub const ontransitionrun = bridge.accessor(HtmlElement.getOnTransitionRun, HtmlElement.setOnTransitionRun, .{}); pub const ontransitionstart = bridge.accessor(HtmlElement.getOnTransitionStart, HtmlElement.setOnTransitionStart, .{}); pub const onvolumechange = bridge.accessor(HtmlElement.getOnVolumeChange, HtmlElement.setOnVolumeChange, .{}); pub const onwaiting = bridge.accessor(HtmlElement.getOnWaiting, HtmlElement.setOnWaiting, .{}); pub const onwheel = bridge.accessor(HtmlElement.getOnWheel, HtmlElement.setOnWheel, .{}); }; pub const Build = struct { // Calls `func_name` with `args` on the most specific type where it is // implement. This could be on the HtmlElement itself. pub fn call(self: *const HtmlElement, comptime func_name: []const u8, args: anytype) !bool { inline for (@typeInfo(HtmlElement.Type).@"union".fields) |f| { if (@field(HtmlElement.Type, f.name) == self._type) { // The inner type implements this function. Call it and we're done. const S = reflect.Struct(f.type); if (@hasDecl(S, "Build")) { if (@hasDecl(S.Build, func_name)) { try @call(.auto, @field(S.Build, func_name), args); return true; } } } } if (@hasDecl(HtmlElement.Build, func_name)) { // Our last resort - the node implements this function. try @call(.auto, @field(HtmlElement.Build, func_name), args); return true; } // inform our caller (the Element) that we didn't find anything that implemented // func_name and it should keep searching for a match. return false; } }; const testing = @import("../../../testing.zig"); test "WebApi: HTML.event_listeners" { try testing.htmlRunner("element/html/event_listeners.html", .{}); } test "WebApi: HTMLElement.props" { try testing.htmlRunner("element/html/htmlelement-props.html", .{}); } ================================================ FILE: src/browser/webapi/element/Svg.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); pub const Generic = @import("svg/Generic.zig"); const Svg = @This(); _type: Type, _proto: *Element, _tag_name: String, // Svg elements are case-preserving pub const Type = union(enum) { svg, generic: *Generic, }; pub fn is(self: *Svg, comptime T: type) ?*T { inline for (@typeInfo(Type).@"union".fields) |f| { if (@field(Type, f.name) == self._type) { if (f.type == T) { return &@field(self._type, f.name); } if (f.type == *T) { return @field(self._type, f.name); } } } return null; } pub fn asElement(self: *Svg) *Element { return self._proto; } pub fn asNode(self: *Svg) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Svg); pub const Meta = struct { pub const name = "SVGElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; const testing = @import("../../../testing.zig"); test "WebApi: Svg" { try testing.htmlRunner("element/svg", .{}); } ================================================ FILE: src/browser/webapi/element/html/Anchor.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const URL = @import("../../../URL.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Anchor = @This(); _proto: *HtmlElement, pub fn asElement(self: *Anchor) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Anchor) *const Element { return self._proto._proto; } pub fn asNode(self: *Anchor) *Node { return self.asElement().asNode(); } pub fn getHref(self: *Anchor, page: *Page) ![]const u8 { const element = self.asElement(); const href = element.getAttributeSafe(comptime .wrap("href")) orelse return ""; if (href.len == 0) { return ""; } return URL.resolve(page.call_arena, page.base(), href, .{ .encode = true }); } pub fn setHref(self: *Anchor, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("href"), .wrap(value), page); } pub fn getTarget(self: *Anchor) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("target")) orelse ""; } pub fn setTarget(self: *Anchor, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("target"), .wrap(value), page); } pub fn getOrigin(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; return (try URL.getOrigin(page.call_arena, href)) orelse "null"; } pub fn getHost(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; const host = URL.getHost(href); const protocol = URL.getProtocol(href); const port = URL.getPort(href); // Strip default ports if (port.len > 0) { if ((std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port, "443")) or (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port, "80"))) { return URL.getHostname(href); } } return host; } pub fn setHost(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; const new_href = try URL.setHost(href, value, page.call_arena); try setHref(self, new_href, page); } pub fn getHostname(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; return URL.getHostname(href); } pub fn setHostname(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; const new_href = try URL.setHostname(href, value, page.call_arena); try setHref(self, new_href, page); } pub fn getPort(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; const port = URL.getPort(href); const protocol = URL.getProtocol(href); // Return empty string for default ports if (port.len > 0) { if ((std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port, "443")) or (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port, "80"))) { return ""; } } return port; } pub fn setPort(self: *Anchor, value: ?[]const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; const new_href = try URL.setPort(href, value, page.call_arena); try setHref(self, new_href, page); } pub fn getSearch(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; return URL.getSearch(href); } pub fn setSearch(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; const new_href = try URL.setSearch(href, value, page.call_arena); try setHref(self, new_href, page); } pub fn getHash(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; return URL.getHash(href); } pub fn setHash(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; const new_href = try URL.setHash(href, value, page.call_arena); try setHref(self, new_href, page); } pub fn getPathname(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; return URL.getPathname(href); } pub fn setPathname(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; const new_href = try URL.setPathname(href, value, page.call_arena); try setHref(self, new_href, page); } pub fn getProtocol(self: *Anchor, page: *Page) ![]const u8 { const href = try getResolvedHref(self, page) orelse return ""; return URL.getProtocol(href); } pub fn setProtocol(self: *Anchor, value: []const u8, page: *Page) !void { const href = try getResolvedHref(self, page) orelse return; const new_href = try URL.setProtocol(href, value, page.call_arena); try setHref(self, new_href, page); } pub fn getType(self: *Anchor) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("type")) orelse ""; } pub fn setType(self: *Anchor, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(value), page); } pub fn getName(self: *const Anchor) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Anchor, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), page); } pub fn getText(self: *Anchor, page: *Page) ![:0]const u8 { return self.asNode().getTextContentAlloc(page.call_arena); } pub fn setText(self: *Anchor, value: []const u8, page: *Page) !void { try self.asNode().setTextContent(value, page); } fn getResolvedHref(self: *Anchor, page: *Page) !?[:0]const u8 { const href = self.asElement().getAttributeSafe(comptime .wrap("href")) orelse return null; if (href.len == 0) { return null; } return try URL.resolve(page.call_arena, page.base(), href, .{}); } pub const JsApi = struct { pub const bridge = js.Bridge(Anchor); pub const Meta = struct { pub const name = "HTMLAnchorElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const href = bridge.accessor(Anchor.getHref, Anchor.setHref, .{}); pub const target = bridge.accessor(Anchor.getTarget, Anchor.setTarget, .{}); pub const name = bridge.accessor(Anchor.getName, Anchor.setName, .{}); pub const origin = bridge.accessor(Anchor.getOrigin, null, .{}); pub const protocol = bridge.accessor(Anchor.getProtocol, Anchor.setProtocol, .{}); pub const host = bridge.accessor(Anchor.getHost, Anchor.setHost, .{}); pub const hostname = bridge.accessor(Anchor.getHostname, Anchor.setHostname, .{}); pub const port = bridge.accessor(Anchor.getPort, Anchor.setPort, .{}); pub const pathname = bridge.accessor(Anchor.getPathname, Anchor.setPathname, .{}); pub const search = bridge.accessor(Anchor.getSearch, Anchor.setSearch, .{}); pub const hash = bridge.accessor(Anchor.getHash, Anchor.setHash, .{}); pub const @"type" = bridge.accessor(Anchor.getType, Anchor.setType, .{}); pub const text = bridge.accessor(Anchor.getText, Anchor.setText, .{}); pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); pub const toString = bridge.function(Anchor.getHref, .{}); fn _getRelList(self: *Anchor, page: *Page) !?*@import("../../collections.zig").DOMTokenList { const element = self.asElement(); // relList is only valid for HTML and SVG <a> elements const namespace = element._namespace; if (namespace != .html and namespace != .svg) { return null; } return element.getRelList(page); } }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Anchor" { try testing.htmlRunner("element/html/anchor.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Area.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Area = @This(); _proto: *HtmlElement, pub fn asElement(self: *Area) *Element { return self._proto._proto; } pub fn asNode(self: *Area) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Area); pub const Meta = struct { pub const name = "HTMLAreaElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Audio.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Media = @import("Media.zig"); const Audio = @This(); _proto: *Media, pub fn constructor(maybe_url: ?String, page: *Page) !*Media { const node = try page.createElementNS(.html, "audio", null); const el = node.as(Element); const list = try el.getOrCreateAttributeList(page); // Always set to "auto" initially. _ = try list.putSafe(comptime .wrap("preload"), comptime .wrap("auto"), el, page); // Set URL if provided. if (maybe_url) |url| { _ = try list.putSafe(comptime .wrap("src"), url, el, page); } return node.as(Media); } pub fn asMedia(self: *Audio) *Media { return self._proto; } pub fn asElement(self: *Audio) *Element { return self._proto.asElement(); } pub fn asNode(self: *Audio) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Audio); pub const Meta = struct { pub const name = "HTMLAudioElement"; pub const constructor_alias = "Audio"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(Audio.constructor, .{}); }; ================================================ FILE: src/browser/webapi/element/html/BR.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const BR = @This(); _proto: *HtmlElement, pub fn asElement(self: *BR) *Element { return self._proto._proto; } pub fn asNode(self: *BR) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(BR); pub const Meta = struct { pub const name = "HTMLBRElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Base.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Base = @This(); _proto: *HtmlElement, pub fn asElement(self: *Base) *Element { return self._proto._proto; } pub fn asNode(self: *Base) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Base); pub const Meta = struct { pub const name = "HTMLBaseElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Body.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Body = @This(); _proto: *HtmlElement, pub fn asElement(self: *Body) *Element { return self._proto._proto; } pub fn asNode(self: *Body) *Node { return self.asElement().asNode(); } /// Special-case: `body.onload` is actually an alias for `window.onload`. pub fn setOnLoad(_: *Body, callback: ?js.Function.Global, page: *Page) !void { page.window._on_load = callback; } /// Special-case: `body.onload` is actually an alias for `window.onload`. pub fn getOnLoad(_: *Body, page: *Page) ?js.Function.Global { return page.window._on_load; } pub const JsApi = struct { pub const bridge = js.Bridge(Body); pub const Meta = struct { pub const name = "HTMLBodyElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const onload = bridge.accessor(getOnLoad, setOnLoad, .{ .null_as_undefined = false }); }; pub const Build = struct { pub fn complete(node: *Node, page: *Page) !void { const el = node.as(Element); const on_load = el.getAttributeSafe(comptime .wrap("onload")) orelse return; if (page.js.stringToPersistedFunction(on_load, &.{"event"}, &.{})) |func| { page.window._on_load = func; } else |err| { log.err(.js, "body.onload", .{ .err = err, .str = on_load }); } } }; ================================================ FILE: src/browser/webapi/element/html/Button.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); const Button = @This(); _proto: *HtmlElement, pub fn asElement(self: *Button) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Button) *const Element { return self._proto._proto; } pub fn asNode(self: *Button) *Node { return self.asElement().asNode(); } pub fn getDisabled(self: *const Button) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn setDisabled(self: *Button, disabled: bool, page: *Page) !void { if (disabled) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } pub fn getName(self: *const Button) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Button, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), page); } pub fn getType(self: *const Button) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("type")) orelse "submit"; } pub fn setType(self: *Button, typ: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(typ), page); } pub fn getValue(self: *const Button) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("value")) orelse ""; } pub fn setValue(self: *Button, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("value"), .wrap(value), page); } pub fn getRequired(self: *const Button) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("required")) != null; } pub fn setRequired(self: *Button, required: bool, page: *Page) !void { if (required) { try self.asElement().setAttributeSafe(comptime .wrap("required"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("required"), page); } } pub fn getForm(self: *Button, page: *Page) ?*Form { const element = self.asElement(); // If form attribute exists, ONLY use that (even if it references nothing) if (element.getAttributeSafe(comptime .wrap("form"))) |form_id| { if (page.document.getElementById(form_id, page)) |form_element| { return form_element.is(Form); } // form attribute present but invalid - no form owner return null; } // No form attribute - traverse ancestors looking for a <form> var node = element.asNode()._parent; while (node) |n| { if (n.is(Element.Html.Form)) |form| { return form; } node = n._parent; } return null; } pub const JsApi = struct { pub const bridge = js.Bridge(Button); pub const Meta = struct { pub const name = "HTMLButtonElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const disabled = bridge.accessor(Button.getDisabled, Button.setDisabled, .{}); pub const name = bridge.accessor(Button.getName, Button.setName, .{}); pub const required = bridge.accessor(Button.getRequired, Button.setRequired, .{}); pub const form = bridge.accessor(Button.getForm, null, .{}); pub const value = bridge.accessor(Button.getValue, Button.setValue, .{}); pub const @"type" = bridge.accessor(Button.getType, Button.setType, .{}); }; pub const Build = struct { pub fn created(_: *Node, _: *Page) !void { // No initialization needed - disabled is lazy from attribute } }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Button" { try testing.htmlRunner("element/html/button.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Canvas.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const CanvasRenderingContext2D = @import("../../canvas/CanvasRenderingContext2D.zig"); const WebGLRenderingContext = @import("../../canvas/WebGLRenderingContext.zig"); const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig"); const Canvas = @This(); _proto: *HtmlElement, pub fn asElement(self: *Canvas) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Canvas) *const Element { return self._proto._proto; } pub fn asNode(self: *Canvas) *Node { return self.asElement().asNode(); } pub fn getWidth(self: *const Canvas) u32 { const attr = self.asConstElement().getAttributeSafe(comptime .wrap("width")) orelse return 300; return std.fmt.parseUnsigned(u32, attr, 10) catch 300; } pub fn setWidth(self: *Canvas, value: u32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("width"), .wrap(str), page); } pub fn getHeight(self: *const Canvas) u32 { const attr = self.asConstElement().getAttributeSafe(comptime .wrap("height")) orelse return 150; return std.fmt.parseUnsigned(u32, attr, 10) catch 150; } pub fn setHeight(self: *Canvas, value: u32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("height"), .wrap(str), page); } /// Since there's no base class rendering contextes inherit from, /// we're using tagged union. const DrawingContext = union(enum) { @"2d": *CanvasRenderingContext2D, webgl: *WebGLRenderingContext, }; pub fn getContext(_: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext { if (std.mem.eql(u8, context_type, "2d")) { const ctx = try page._factory.create(CanvasRenderingContext2D{}); return .{ .@"2d" = ctx }; } if (std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl")) { const ctx = try page._factory.create(WebGLRenderingContext{}); return .{ .webgl = ctx }; } return null; } /// Transfers control of the canvas to an OffscreenCanvas. /// Returns an OffscreenCanvas with the same dimensions. pub fn transferControlToOffscreen(self: *Canvas, page: *Page) !*OffscreenCanvas { const width = self.getWidth(); const height = self.getHeight(); return OffscreenCanvas.constructor(width, height, page); } pub const JsApi = struct { pub const bridge = js.Bridge(Canvas); pub const Meta = struct { pub const name = "HTMLCanvasElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const width = bridge.accessor(Canvas.getWidth, Canvas.setWidth, .{}); pub const height = bridge.accessor(Canvas.getHeight, Canvas.setHeight, .{}); pub const getContext = bridge.function(Canvas.getContext, .{}); pub const transferControlToOffscreen = bridge.function(Canvas.transferControlToOffscreen, .{}); }; ================================================ FILE: src/browser/webapi/element/html/Custom.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const log = @import("../../../../log.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const CustomElementDefinition = @import("../../CustomElementDefinition.zig"); const Custom = @This(); _proto: *HtmlElement, _tag_name: String, _definition: ?*CustomElementDefinition, _connected_callback_invoked: bool = false, _disconnected_callback_invoked: bool = false, pub fn asElement(self: *Custom) *Element { return self._proto._proto; } pub fn asNode(self: *Custom) *Node { return self.asElement().asNode(); } pub fn invokeConnectedCallback(self: *Custom, page: *Page) void { // Only invoke if we haven't already called it while connected if (self._connected_callback_invoked) { return; } self._connected_callback_invoked = true; self._disconnected_callback_invoked = false; self.invokeCallback("connectedCallback", .{}, page); } pub fn invokeDisconnectedCallback(self: *Custom, page: *Page) void { // Only invoke if we haven't already called it while disconnected if (self._disconnected_callback_invoked) { return; } self._disconnected_callback_invoked = true; self._connected_callback_invoked = false; self.invokeCallback("disconnectedCallback", .{}, page); } pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?String, new_value: ?String, page: *Page) void { const definition = self._definition orelse return; if (!definition.isAttributeObserved(name)) { return; } self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value }, page); } pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, page: *Page) !void { // Autonomous custom element if (element.is(Custom)) |custom| { // If the element is undefined, check if a definition now exists and upgrade if (custom._definition == null) { const name = custom._tag_name.str(); if (page.window._custom_elements._definitions.get(name)) |definition| { const CustomElementRegistry = @import("../../CustomElementRegistry.zig"); CustomElementRegistry.upgradeCustomElement(custom, definition, page) catch {}; return; } } if (comptime from_parser) { // From parser, we know the element is brand new custom._connected_callback_invoked = true; custom.invokeCallback("connectedCallback", .{}, page); } else { custom.invokeConnectedCallback(page); } return; } // Customized built-in element - check if it actually has a definition first const definition = page.getCustomizedBuiltInDefinition(element) orelse return; if (comptime from_parser) { // From parser, we know the element is brand new, skip the tracking check try page._customized_builtin_connected_callback_invoked.put( page.arena, element, {}, ); } else { // Not from parser, check if we've already invoked while connected const gop = try page._customized_builtin_connected_callback_invoked.getOrPut( page.arena, element, ); if (gop.found_existing) { return; } gop.value_ptr.* = {}; } _ = page._customized_builtin_disconnected_callback_invoked.remove(element); invokeCallbackOnElement(element, definition, "connectedCallback", .{}, page); } pub fn invokeDisconnectedCallbackOnElement(element: *Element, page: *Page) void { // Autonomous custom element if (element.is(Custom)) |custom| { custom.invokeDisconnectedCallback(page); return; } // Customized built-in element - check if it actually has a definition first const definition = page.getCustomizedBuiltInDefinition(element) orelse return; // Check if we've already invoked disconnectedCallback while disconnected const gop = page._customized_builtin_disconnected_callback_invoked.getOrPut( page.arena, element, ) catch return; if (gop.found_existing) return; gop.value_ptr.* = {}; _ = page._customized_builtin_connected_callback_invoked.remove(element); invokeCallbackOnElement(element, definition, "disconnectedCallback", .{}, page); } pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: String, old_value: ?String, new_value: ?String, page: *Page) void { // Autonomous custom element if (element.is(Custom)) |custom| { custom.invokeAttributeChangedCallback(name, old_value, new_value, page); return; } // Customized built-in element - check if attribute is observed const definition = page.getCustomizedBuiltInDefinition(element) orelse return; if (!definition.isAttributeObserved(name)) return; invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value }, page); } fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, page: *Page) void { _ = definition; var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); // Get the JS element object const js_val = ls.local.zigValueToJs(element, .{}) catch return; const js_element = js_val.toObject(); // Call the callback method if it exists js_element.callMethod(void, callback_name, args) catch return; } // Check if element has "is" attribute and attach customized built-in definition pub fn checkAndAttachBuiltIn(element: *Element, page: *Page) !void { const is_value = element.getAttributeSafe(comptime .wrap("is")) orelse return; const custom_elements = page.window.getCustomElements(); const definition = custom_elements._definitions.get(is_value) orelse return; const extends_tag = definition.extends orelse return; if (extends_tag != element.getTag()) { return; } // Attach the definition try page.setCustomizedBuiltInDefinition(element, definition); // Reset callback flags since this is a fresh upgrade _ = page._customized_builtin_connected_callback_invoked.remove(element); _ = page._customized_builtin_disconnected_callback_invoked.remove(element); // Invoke constructor const prev_upgrading = page._upgrading_element; const node = element.asNode(); page._upgrading_element = node; defer page._upgrading_element = prev_upgrading; // PERFORMANCE OPTIMIZATION: This pattern is discouraged in general code. // Used here because: (1) multiple early returns before needing Local, // (2) called from both V8 callbacks (Local exists) and parser (no Local). // Prefer either: requiring *const js.Local parameter, OR always creating // Local.Scope upfront. var ls: ?js.Local.Scope = null; var local = blk: { if (page.js.local) |l| { break :blk l; } ls = undefined; page.js.localScope(&ls.?); break :blk &ls.?.local; }; defer if (ls) |*_ls| { _ls.deinit(); }; var caught: js.TryCatch.Caught = undefined; _ = local.toLocal(definition.constructor).newInstance(&caught) catch |err| { log.warn(.js, "custom builtin ctor", .{ .name = is_value, .err = err, .caught = caught }); return; }; } fn invokeCallback(self: *Custom, comptime callback_name: [:0]const u8, args: anytype, page: *Page) void { if (self._definition == null) { return; } var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); const js_val = ls.local.zigValueToJs(self, .{}) catch return; const js_element = js_val.toObject(); js_element.callMethod(void, callback_name, args) catch return; } pub const JsApi = struct { pub const bridge = js.Bridge(Custom); pub const Meta = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/DList.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const DList = @This(); _proto: *HtmlElement, pub fn asElement(self: *DList) *Element { return self._proto._proto; } pub fn asNode(self: *DList) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(DList); pub const Meta = struct { pub const name = "HTMLDListElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Data.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Data = @This(); _proto: *HtmlElement, pub fn asElement(self: *Data) *Element { return self._proto._proto; } pub fn asNode(self: *Data) *Node { return self.asElement().asNode(); } pub fn getValue(self: *Data) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("value")) orelse ""; } pub fn setValue(self: *Data, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("value"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Data); pub const Meta = struct { pub const name = "HTMLDataElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const value = bridge.accessor(Data.getValue, Data.setValue, .{}); }; ================================================ FILE: src/browser/webapi/element/html/DataList.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const DataList = @This(); _proto: *HtmlElement, pub fn asElement(self: *DataList) *Element { return self._proto._proto; } pub fn asNode(self: *DataList) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(DataList); pub const Meta = struct { pub const name = "HTMLDataListElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Details.zig ================================================ const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Details = @This(); _proto: *HtmlElement, pub fn asElement(self: *Details) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Details) *const Element { return self._proto._proto; } pub fn asNode(self: *Details) *Node { return self.asElement().asNode(); } pub fn getOpen(self: *const Details) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("open")) != null; } pub fn setOpen(self: *Details, open: bool, page: *Page) !void { if (open) { try self.asElement().setAttributeSafe(comptime .wrap("open"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("open"), page); } } pub fn getName(self: *const Details) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Details, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Details); pub const Meta = struct { pub const name = "HTMLDetailsElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const open = bridge.accessor(Details.getOpen, Details.setOpen, .{}); pub const name = bridge.accessor(Details.getName, Details.setName, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Details" { try testing.htmlRunner("element/html/details.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Dialog.zig ================================================ const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Dialog = @This(); _proto: *HtmlElement, pub fn asElement(self: *Dialog) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Dialog) *const Element { return self._proto._proto; } pub fn asNode(self: *Dialog) *Node { return self.asElement().asNode(); } pub fn getOpen(self: *const Dialog) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("open")) != null; } pub fn setOpen(self: *Dialog, open: bool, page: *Page) !void { if (open) { try self.asElement().setAttributeSafe(comptime .wrap("open"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("open"), page); } } pub fn getReturnValue(self: *const Dialog) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("returnvalue")) orelse ""; } pub fn setReturnValue(self: *Dialog, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("returnvalue"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Dialog); pub const Meta = struct { pub const name = "HTMLDialogElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const open = bridge.accessor(Dialog.getOpen, Dialog.setOpen, .{}); pub const returnValue = bridge.accessor(Dialog.getReturnValue, Dialog.setReturnValue, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Dialog" { try testing.htmlRunner("element/html/dialog.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Directory.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Directory = @This(); _proto: *HtmlElement, pub fn asElement(self: *Directory) *Element { return self._proto._proto; } pub fn asNode(self: *Directory) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Directory); pub const Meta = struct { pub const name = "HTMLDirectoryElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Div.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Div = @This(); _proto: *HtmlElement, pub fn asElement(self: *Div) *Element { return self._proto._proto; } pub fn asNode(self: *Div) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Div); pub const Meta = struct { pub const name = "HTMLDivElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Embed.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Embed = @This(); _proto: *HtmlElement, pub fn asElement(self: *Embed) *Element { return self._proto._proto; } pub fn asNode(self: *Embed) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Embed); pub const Meta = struct { pub const name = "HTMLEmbedElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/FieldSet.zig ================================================ const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const FieldSet = @This(); _proto: *HtmlElement, pub fn asElement(self: *FieldSet) *Element { return self._proto._proto; } pub fn asNode(self: *FieldSet) *Node { return self.asElement().asNode(); } pub fn getDisabled(self: *FieldSet) bool { return self.asElement().getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn setDisabled(self: *FieldSet, value: bool, page: *Page) !void { if (value) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } pub fn getName(self: *FieldSet) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *FieldSet, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(FieldSet); pub const Meta = struct { pub const name = "HTMLFieldSetElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const disabled = bridge.accessor(FieldSet.getDisabled, FieldSet.setDisabled, .{}); pub const name = bridge.accessor(FieldSet.getName, FieldSet.setName, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.FieldSet" { try testing.htmlRunner("element/html/fieldset.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Font.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Font = @This(); _proto: *HtmlElement, pub fn asElement(self: *Font) *Element { return self._proto._proto; } pub fn asNode(self: *Font) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Font); pub const Meta = struct { pub const name = "HTMLFontElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Form.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const URL = @import("../../../URL.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const collections = @import("../../collections.zig"); pub const Input = @import("Input.zig"); pub const Button = @import("Button.zig"); pub const Select = @import("Select.zig"); pub const TextArea = @import("TextArea.zig"); const Form = @This(); _proto: *HtmlElement, pub fn asHtmlElement(self: *Form) *HtmlElement { return self._proto; } fn asConstElement(self: *const Form) *const Element { return self._proto._proto; } pub fn asElement(self: *Form) *Element { return self._proto._proto; } pub fn asNode(self: *Form) *Node { return self.asElement().asNode(); } pub fn getName(self: *const Form) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Form, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), page); } pub fn getMethod(self: *const Form) []const u8 { const method = self.asConstElement().getAttributeSafe(comptime .wrap("method")) orelse return "get"; if (std.ascii.eqlIgnoreCase(method, "post")) { return "post"; } if (std.ascii.eqlIgnoreCase(method, "dialog")) { return "dialog"; } // invalid, or it was get all along return "get"; } pub fn setMethod(self: *Form, method: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("method"), .wrap(method), page); } pub fn getElements(self: *Form, page: *Page) !*collections.HTMLFormControlsCollection { const form_id = self.asElement().getAttributeSafe(comptime .wrap("id")); const root = if (form_id != null) self.asNode().getRootNode(null) // Has ID: walk entire document to find form=ID controls else self.asNode(); // No ID: walk only form subtree (no external controls possible) const node_live = collections.NodeLive(.form).init(root, self, page); const html_collection = try node_live.runtimeGenericWrap(page); return page._factory.create(collections.HTMLFormControlsCollection{ ._proto = html_collection, }); } pub fn getAction(self: *Form, page: *Page) ![]const u8 { const element = self.asElement(); const action = element.getAttributeSafe(comptime .wrap("action")) orelse return page.url; if (action.len == 0) { return page.url; } return URL.resolve(page.call_arena, page.base(), action, .{ .encode = true }); } pub fn setAction(self: *Form, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("action"), .wrap(value), page); } pub fn getTarget(self: *Form) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("target")) orelse ""; } pub fn setTarget(self: *Form, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("target"), .wrap(value), page); } pub fn getLength(self: *Form, page: *Page) !u32 { const elements = try self.getElements(page); return elements.length(page); } pub fn submit(self: *Form, page: *Page) !void { return page.submitForm(null, self, .{ .fire_event = false }); } /// https://html.spec.whatwg.org/multipage/forms.html#dom-form-requestsubmit /// Like submit(), but fires the submit event and validates the form. pub fn requestSubmit(self: *Form, submitter: ?*Element, page: *Page) !void { const submitter_element = if (submitter) |s| blk: { // The submitter must be a submit button. if (!isSubmitButton(s)) return error.TypeError; // The submitter's form owner must be this form element. const submitter_form = getFormOwner(s, page); if (submitter_form == null or submitter_form.? != self) return error.NotFound; break :blk s; } else self.asElement(); return page.submitForm(submitter_element, self, .{}); } /// Returns true if the element is a submit button per the HTML spec: /// - <input type="submit"> or <input type="image"> /// - <button type="submit"> (including default, since button's default type is "submit") fn isSubmitButton(element: *Element) bool { if (element.is(Input)) |input| { return input._input_type == .submit or input._input_type == .image; } if (element.is(Button)) |button| { return std.mem.eql(u8, button.getType(), "submit"); } return false; } /// Returns the form owner of a submittable element (Input or Button). fn getFormOwner(element: *Element, page: *Page) ?*Form { if (element.is(Input)) |input| { return input.getForm(page); } if (element.is(Button)) |button| { return button.getForm(page); } return null; } pub const JsApi = struct { pub const bridge = js.Bridge(Form); pub const Meta = struct { pub const name = "HTMLFormElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const name = bridge.accessor(Form.getName, Form.setName, .{}); pub const method = bridge.accessor(Form.getMethod, Form.setMethod, .{}); pub const action = bridge.accessor(Form.getAction, Form.setAction, .{}); pub const target = bridge.accessor(Form.getTarget, Form.setTarget, .{}); pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{}); pub const submit = bridge.function(Form.submit, .{}); pub const requestSubmit = bridge.function(Form.requestSubmit, .{ .dom_exception = true }); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Form" { try testing.htmlRunner("element/html/form.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Generic.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Generic = @This(); _tag_name: String, _tag: Element.Tag, _proto: *HtmlElement, pub fn asElement(self: *Generic) *Element { return self._proto._proto; } pub fn asNode(self: *Generic) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Generic); pub const Meta = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/HR.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const HR = @This(); _proto: *HtmlElement, pub fn asElement(self: *HR) *Element { return self._proto._proto; } pub fn asNode(self: *HR) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(HR); pub const Meta = struct { pub const name = "HTMLHRElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Head.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Head = @This(); _proto: *HtmlElement, pub fn asElement(self: *Head) *Element { return self._proto._proto; } pub fn asNode(self: *Head) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Head); pub const Meta = struct { pub const name = "HTMLHeadElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Heading.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Heading = @This(); _proto: *HtmlElement, _tag_name: String, _tag: Element.Tag, pub fn asElement(self: *Heading) *Element { return self._proto._proto; } pub fn asNode(self: *Heading) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Heading); pub const Meta = struct { pub const name = "HTMLHeadingElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Html.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Html = @This(); _proto: *HtmlElement, pub fn asElement(self: *Html) *Element { return self._proto._proto; } pub fn asNode(self: *Html) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Html); pub const Meta = struct { pub const name = "HTMLHtmlElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/IFrame.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Window = @import("../../Window.zig"); const Document = @import("../../Document.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const URL = @import("../../URL.zig"); const IFrame = @This(); _proto: *HtmlElement, _src: []const u8 = "", _executed: bool = false, _window: ?*Window = null, pub fn asElement(self: *IFrame) *Element { return self._proto._proto; } pub fn asNode(self: *IFrame) *Node { return self.asElement().asNode(); } pub fn getContentWindow(self: *const IFrame) ?*Window { return self._window; } pub fn getContentDocument(self: *const IFrame) ?*Document { const window = self._window orelse return null; return window._document; } pub fn getSrc(self: *const IFrame, page: *Page) ![:0]const u8 { if (self._src.len == 0) return ""; return try URL.resolve(page.call_arena, page.base(), self._src, .{ .encode = true }); } pub fn setSrc(self: *IFrame, src: []const u8, page: *Page) !void { const element = self.asElement(); try element.setAttributeSafe(comptime .wrap("src"), .wrap(src), page); self._src = element.getAttributeSafe(comptime .wrap("src")) orelse unreachable; if (element.asNode().isConnected()) { // unlike script, an iframe is reloaded every time the src is set // even if it's set to the same URL. self._executed = false; try page.iframeAddedCallback(self); } } pub fn getName(self: *IFrame) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *IFrame, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(IFrame); pub const Meta = struct { pub const name = "HTMLIFrameElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{}); pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{}); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); }; pub const Build = struct { pub fn complete(node: *Node, _: *Page) !void { const self = node.as(IFrame); const element = self.asElement(); self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; } }; ================================================ FILE: src/browser/webapi/element/html/Image.zig ================================================ const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const URL = @import("../../../URL.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Event = @import("../../Event.zig"); const log = @import("../../../../log.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const Image = @This(); _proto: *HtmlElement, pub fn constructor(w_: ?u32, h_: ?u32, page: *Page) !*Image { const node = try page.createElementNS(.html, "img", null); const el = node.as(Element); if (w_) |w| blk: { const w_string = std.fmt.bufPrint(&page.buf, "{d}", .{w}) catch break :blk; try el.setAttributeSafe(comptime .wrap("width"), .wrap(w_string), page); } if (h_) |h| blk: { const h_string = std.fmt.bufPrint(&page.buf, "{d}", .{h}) catch break :blk; try el.setAttributeSafe(comptime .wrap("height"), .wrap(h_string), page); } return el.as(Image); } pub fn asElement(self: *Image) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Image) *const Element { return self._proto._proto; } pub fn asNode(self: *Image) *Node { return self.asElement().asNode(); } pub fn getSrc(self: *const Image, page: *Page) ![]const u8 { const element = self.asConstElement(); const src = element.getAttributeSafe(comptime .wrap("src")) orelse return ""; if (src.len == 0) { return ""; } // Always resolve the src against the page URL return URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }); } pub fn setSrc(self: *Image, value: []const u8, page: *Page) !void { const element = self.asElement(); try element.setAttributeSafe(comptime .wrap("src"), .wrap(value), page); // No need to check if `Image` is connected to DOM; this is a special case. return self.imageAddedCallback(page); } pub fn getAlt(self: *const Image) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("alt")) orelse ""; } pub fn setAlt(self: *Image, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("alt"), .wrap(value), page); } pub fn getWidth(self: *const Image) u32 { const attr = self.asConstElement().getAttributeSafe(comptime .wrap("width")) orelse return 0; return std.fmt.parseUnsigned(u32, attr, 10) catch 0; } pub fn setWidth(self: *Image, value: u32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("width"), .wrap(str), page); } pub fn getHeight(self: *const Image) u32 { const attr = self.asConstElement().getAttributeSafe(comptime .wrap("height")) orelse return 0; return std.fmt.parseUnsigned(u32, attr, 10) catch 0; } pub fn setHeight(self: *Image, value: u32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("height"), .wrap(str), page); } pub fn getCrossOrigin(self: *const Image) ?[]const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("crossorigin")); } pub fn setCrossOrigin(self: *Image, value: ?[]const u8, page: *Page) !void { if (value) |v| { return self.asElement().setAttributeSafe(comptime .wrap("crossorigin"), .wrap(v), page); } return self.asElement().removeAttribute(comptime .wrap("crossorigin"), page); } pub fn getLoading(self: *const Image) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("loading")) orelse "eager"; } pub fn setLoading(self: *Image, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("loading"), .wrap(value), page); } pub fn getNaturalWidth(_: *const Image) u32 { // this is a valid response under a number of normal conditions, but could // be used to detect the nature of Browser. return 0; } pub fn getNaturalHeight(_: *const Image) u32 { // this is a valid response under a number of normal conditions, but could // be used to detect the nature of Browser. return 0; } pub fn getComplete(_: *const Image) bool { // Per spec, complete is true when: no src/srcset, src is empty, // image is fully available, or image is broken (with no pending request). // Since we never fetch images, they are in the "broken" state, which has // complete=true. This is consistent with naturalWidth/naturalHeight=0. return true; } /// Used in `Page.nodeIsReady`. pub fn imageAddedCallback(self: *Image, page: *Page) !void { // if we're planning on navigating to another page, don't trigger load event. if (page.isGoingAway()) { return; } const element = self.asElement(); // Exit if src not set. const src = element.getAttributeSafe(comptime .wrap("src")) orelse return; if (src.len == 0) return; try page._to_load.append(page.arena, self._proto); } pub const JsApi = struct { pub const bridge = js.Bridge(Image); pub const Meta = struct { pub const name = "HTMLImageElement"; pub const constructor_alias = "Image"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(Image.constructor, .{}); pub const src = bridge.accessor(Image.getSrc, Image.setSrc, .{}); pub const alt = bridge.accessor(Image.getAlt, Image.setAlt, .{}); pub const width = bridge.accessor(Image.getWidth, Image.setWidth, .{}); pub const height = bridge.accessor(Image.getHeight, Image.setHeight, .{}); pub const crossOrigin = bridge.accessor(Image.getCrossOrigin, Image.setCrossOrigin, .{}); pub const loading = bridge.accessor(Image.getLoading, Image.setLoading, .{}); pub const naturalWidth = bridge.accessor(Image.getNaturalWidth, null, .{}); pub const naturalHeight = bridge.accessor(Image.getNaturalHeight, null, .{}); pub const complete = bridge.accessor(Image.getComplete, null, .{}); }; pub const Build = struct { pub fn created(node: *Node, page: *Page) !void { const self = node.as(Image); return self.imageAddedCallback(page); } }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Image" { try testing.htmlRunner("element/html/image.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Input.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); const Selection = @import("../../Selection.zig"); const Event = @import("../../Event.zig"); const InputEvent = @import("../../event/InputEvent.zig"); const Input = @This(); pub const Type = enum { text, password, checkbox, radio, submit, reset, button, hidden, image, file, email, url, tel, search, number, range, date, time, @"datetime-local", month, week, color, pub fn fromString(str: []const u8) Type { // Longest type name is "datetime-local" at 14 chars if (str.len > 32) { return .text; } var buf: [32]u8 = undefined; const lower = std.ascii.lowerString(&buf, str); return std.meta.stringToEnum(Type, lower) orelse .text; } pub fn toString(self: Type) []const u8 { return @tagName(self); } }; _proto: *HtmlElement, _default_value: ?[]const u8 = null, _default_checked: bool = false, _value: ?[]const u8 = null, _checked: bool = false, _checked_dirty: bool = false, _input_type: Type = .text, _indeterminate: bool = false, _selection_start: u32 = 0, _selection_end: u32 = 0, _selection_direction: Selection.SelectionDirection = .none, _on_selectionchange: ?js.Function.Global = null, pub fn getOnSelectionChange(self: *Input) ?js.Function.Global { return self._on_selectionchange; } pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void { if (listener) |listen| { self._on_selectionchange = try listen.persistWithThis(self); } else { self._on_selectionchange = null; } } fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void { const event = try Event.init("selectionchange", .{ .bubbles = true }, page); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, page: *Page) !void { const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page); try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent()); } pub fn asElement(self: *Input) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Input) *const Element { return self._proto._proto; } pub fn asNode(self: *Input) *Node { return self.asElement().asNode(); } pub fn getType(self: *const Input) []const u8 { return self._input_type.toString(); } pub fn setType(self: *Input, typ: []const u8, page: *Page) !void { // Setting the type property should update the attribute, which will trigger attributeChange const type_enum = Type.fromString(typ); try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(type_enum.toString()), page); } pub fn getValue(self: *const Input) []const u8 { if (self._input_type == .file) return ""; return self._value orelse self._default_value orelse switch (self._input_type) { .checkbox, .radio => "on", else => "", }; } pub fn setValue(self: *Input, value: []const u8, page: *Page) !void { // File inputs: setting to empty string is a no-op, anything else throws if (self._input_type == .file) { if (value.len == 0) return; return error.InvalidStateError; } // This should _not_ call setAttribute. It updates the current state only self._value = try self.sanitizeValue(true, value, page); } pub fn getDefaultValue(self: *const Input) []const u8 { return self._default_value orelse ""; } pub fn setDefaultValue(self: *Input, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("value"), .wrap(value), page); } pub fn getChecked(self: *const Input) bool { return self._checked; } pub fn setChecked(self: *Input, checked: bool, page: *Page) !void { // If checking a radio button, uncheck others in the group first if (checked and self._input_type == .radio) { try self.uncheckRadioGroup(page); } // This should _not_ call setAttribute. It updates the current state only self._checked = checked; self._checked_dirty = true; } pub fn getIndeterminate(self: *const Input) bool { return self._indeterminate; } pub fn setIndeterminate(self: *Input, value: bool) !void { self._indeterminate = value; } pub fn getDefaultChecked(self: *const Input) bool { return self._default_checked; } pub fn setDefaultChecked(self: *Input, checked: bool, page: *Page) !void { if (checked) { try self.asElement().setAttributeSafe(comptime .wrap("checked"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("checked"), page); } } pub fn getWillValidate(self: *const Input) bool { // An input element is barred from constraint validation if: // - type is hidden, button, or reset // - element is disabled // - element has a datalist ancestor return switch (self._input_type) { .hidden, .button, .reset => false, else => !self.getDisabled() and !self.hasDatalistAncestor(), }; } fn hasDatalistAncestor(self: *const Input) bool { var node = self.asConstElement().asConstNode().parentElement(); while (node) |parent| { if (parent.is(HtmlElement.DataList) != null) return true; node = parent.asConstNode().parentElement(); } return false; } pub fn getDisabled(self: *const Input) bool { // TODO: Also check for disabled fieldset ancestors // (but not if we're inside a <legend> of that fieldset) return self.asConstElement().getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn setDisabled(self: *Input, disabled: bool, page: *Page) !void { if (disabled) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } pub fn getName(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Input, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), page); } pub fn getAccept(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("accept")) orelse ""; } pub fn setAccept(self: *Input, accept: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("accept"), .wrap(accept), page); } pub fn getAlt(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("alt")) orelse ""; } pub fn setAlt(self: *Input, alt: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("alt"), .wrap(alt), page); } pub fn getMaxLength(self: *const Input) i32 { const attr = self.asConstElement().getAttributeSafe(comptime .wrap("maxlength")) orelse return -1; return std.fmt.parseInt(i32, attr, 10) catch -1; } pub fn setMaxLength(self: *Input, max_length: i32, page: *Page) !void { if (max_length < 0) { return error.IndexSizeError; } var buf: [32]u8 = undefined; const value = std.fmt.bufPrint(&buf, "{d}", .{max_length}) catch unreachable; try self.asElement().setAttributeSafe(comptime .wrap("maxlength"), .wrap(value), page); } pub fn getSize(self: *const Input) i32 { const attr = self.asConstElement().getAttributeSafe(comptime .wrap("size")) orelse return 20; const parsed = std.fmt.parseInt(i32, attr, 10) catch return 20; return if (parsed == 0) 20 else parsed; } pub fn setSize(self: *Input, size: i32, page: *Page) !void { if (size == 0) { return error.ZeroNotAllowed; } if (size < 0) { return self.asElement().setAttributeSafe(comptime .wrap("size"), .wrap("20"), page); } var buf: [32]u8 = undefined; const value = std.fmt.bufPrint(&buf, "{d}", .{size}) catch unreachable; try self.asElement().setAttributeSafe(comptime .wrap("size"), .wrap(value), page); } pub fn getSrc(self: *const Input, page: *Page) ![]const u8 { const src = self.asConstElement().getAttributeSafe(comptime .wrap("src")) orelse return ""; // If attribute is explicitly set (even if empty), resolve it against the base URL return @import("../../URL.zig").resolve(page.call_arena, page.base(), src, .{}); } pub fn setSrc(self: *Input, src: []const u8, page: *Page) !void { const trimmed = std.mem.trim(u8, src, &std.ascii.whitespace); try self.asElement().setAttributeSafe(comptime .wrap("src"), .wrap(trimmed), page); } pub fn getReadonly(self: *const Input) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("readonly")) != null; } pub fn setReadonly(self: *Input, readonly: bool, page: *Page) !void { if (readonly) { try self.asElement().setAttributeSafe(comptime .wrap("readonly"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("readonly"), page); } } pub fn getRequired(self: *const Input) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("required")) != null; } pub fn setRequired(self: *Input, required: bool, page: *Page) !void { if (required) { try self.asElement().setAttributeSafe(comptime .wrap("required"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("required"), page); } } pub fn getPlaceholder(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("placeholder")) orelse ""; } pub fn setPlaceholder(self: *Input, placeholder: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("placeholder"), .wrap(placeholder), page); } pub fn getMin(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("min")) orelse ""; } pub fn setMin(self: *Input, min: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("min"), .wrap(min), page); } pub fn getMax(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("max")) orelse ""; } pub fn setMax(self: *Input, max: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("max"), .wrap(max), page); } pub fn getStep(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("step")) orelse ""; } pub fn setStep(self: *Input, step: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("step"), .wrap(step), page); } pub fn getMultiple(self: *const Input) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("multiple")) != null; } pub fn setMultiple(self: *Input, multiple: bool, page: *Page) !void { if (multiple) { try self.asElement().setAttributeSafe(comptime .wrap("multiple"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("multiple"), page); } } pub fn getAutocomplete(self: *const Input) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("autocomplete")) orelse ""; } pub fn setAutocomplete(self: *Input, autocomplete: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("autocomplete"), .wrap(autocomplete), page); } pub fn select(self: *Input, page: *Page) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; try self.setSelectionRange(0, len, null, page); const event = try Event.init("select", .{ .bubbles = true }, page); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } fn selectionAvailable(self: *const Input) bool { switch (self._input_type) { .text, .search, .url, .tel, .password => return true, else => return false, } } const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; fn howSelected(self: *const Input) HowSelected { if (!self.selectionAvailable()) return .none; const value = self._value orelse return .none; if (self._selection_start == self._selection_end) return .none; if (self._selection_start == 0 and self._selection_end == value.len) return .full; return .{ .partial = .{ self._selection_start, self._selection_end } }; } pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void { const arena = page.arena; switch (self.howSelected()) { .full => { // if the input is fully selected, replace the content. const new_value = try arena.dupe(u8, str); try self.setValue(new_value, page); self._selection_start = @intCast(new_value.len); self._selection_end = @intCast(new_value.len); self._selection_direction = .none; try self.dispatchSelectionChangeEvent(page); }, .partial => |range| { // if the input is partially selected, replace the selected content. const current_value = self.getValue(); const before = current_value[0..range[0]]; const remaining = current_value[range[1]..]; const new_value = try std.mem.concat( arena, u8, &.{ before, str, remaining }, ); try self.setValue(new_value, page); const new_pos = range[0] + str.len; self._selection_start = @intCast(new_pos); self._selection_end = @intCast(new_pos); self._selection_direction = .none; try self.dispatchSelectionChangeEvent(page); }, .none => { // if the input is not selected, just insert at cursor. const current_value = self.getValue(); const new_value = try std.mem.concat(arena, u8, &.{ current_value, str }); try self.setValue(new_value, page); }, } try self.dispatchInputEvent(str, "insertText", page); } pub fn getSelectionDirection(self: *const Input) []const u8 { return @tagName(self._selection_direction); } pub fn getSelectionStart(self: *const Input) !?u32 { if (!self.selectionAvailable()) return null; return self._selection_start; } pub fn setSelectionStart(self: *Input, value: u32, page: *Page) !void { if (!self.selectionAvailable()) return error.InvalidStateError; self._selection_start = value; try self.dispatchSelectionChangeEvent(page); } pub fn getSelectionEnd(self: *const Input) !?u32 { if (!self.selectionAvailable()) return null; return self._selection_end; } pub fn setSelectionEnd(self: *Input, value: u32, page: *Page) !void { if (!self.selectionAvailable()) return error.InvalidStateError; self._selection_end = value; try self.dispatchSelectionChangeEvent(page); } pub fn setSelectionRange( self: *Input, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8, page: *Page, ) !void { if (!self.selectionAvailable()) return error.InvalidStateError; const direction = blk: { if (selection_dir) |sd| { break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none; } else break :blk .none; }; const value = self._value orelse { self._selection_start = 0; self._selection_end = 0; self._selection_direction = .none; return; }; const len_u32: u32 = @intCast(value.len); var start: u32 = if (selection_start > len_u32) len_u32 else selection_start; const end: u32 = if (selection_end > len_u32) len_u32 else selection_end; // If end is less than start, both are equal to end. if (end < start) { start = end; } self._selection_direction = direction; self._selection_start = start; self._selection_end = end; try self.dispatchSelectionChangeEvent(page); } pub fn getForm(self: *Input, page: *Page) ?*Form { const element = self.asElement(); // If form attribute exists, ONLY use that (even if it references nothing) if (element.getAttributeSafe(comptime .wrap("form"))) |form_id| { if (page.document.getElementById(form_id, page)) |form_element| { return form_element.is(Form); } // form attribute present but invalid - no form owner return null; } // No form attribute - traverse ancestors looking for a <form> var node = element.asNode()._parent; while (node) |n| { if (n.is(Element.Html.Form)) |form| { return form; } node = n._parent; } return null; } /// Sanitize the value according to the current input type fn sanitizeValue(self: *Input, comptime dupe: bool, value: []const u8, page: *Page) ![]const u8 { switch (self._input_type) { .text, .search, .tel, .password, .url, .email => { const sanitized = blk: { const first = std.mem.indexOfAny(u8, value, "\r\n") orelse { break :blk if (comptime dupe) try page.dupeString(value) else value; }; var result = try page.arena.alloc(u8, value.len); @memcpy(result[0..first], value[0..first]); var i: usize = first; for (value[first + 1 ..]) |c| { if (c != '\r' and c != '\n') { result[i] = c; i += 1; } } break :blk result[0..i]; }; return switch (self._input_type) { .url, .email => std.mem.trim(u8, sanitized, &std.ascii.whitespace), else => sanitized, }; }, .date => return if (isValidDate(value)) if (comptime dupe) try page.dupeString(value) else value else "", .month => return if (isValidMonth(value)) if (comptime dupe) try page.dupeString(value) else value else "", .week => return if (isValidWeek(value)) if (comptime dupe) try page.dupeString(value) else value else "", .time => return if (isValidTime(value)) if (comptime dupe) try page.dupeString(value) else value else "", .@"datetime-local" => return try sanitizeDatetimeLocal(dupe, value, page.arena), .number => return if (isValidFloatingPoint(value)) if (comptime dupe) try page.dupeString(value) else value else "", .range => return if (isValidFloatingPoint(value)) if (comptime dupe) try page.dupeString(value) else value else "50", .color => { if (value.len == 7 and value[0] == '#') { var needs_lower = false; for (value[1..]) |c| { if (!std.ascii.isHex(c)) { return "#000000"; } if (c >= 'A' and c <= 'F') { needs_lower = true; } } if (!needs_lower) { return if (comptime dupe) try page.dupeString(value) else value; } // Normalize to lowercase per spec const result = try page.arena.alloc(u8, 7); result[0] = '#'; for (value[1..], 1..) |c, j| { result[j] = std.ascii.toLower(c); } return result; } return "#000000"; }, .file => return "", // File: always empty .checkbox, .radio, .submit, .image, .reset, .button, .hidden => return if (comptime dupe) try page.dupeString(value) else value, // no sanitization } } /// WHATWG "valid floating-point number" grammar check + overflow detection. /// Rejects "+1", "1.", "Infinity", "NaN", "2e308", leading whitespace, trailing junk. fn isValidFloatingPoint(value: []const u8) bool { if (value.len == 0) return false; var pos: usize = 0; // Optional leading minus (no plus allowed) if (value[pos] == '-') { pos += 1; if (pos >= value.len) return false; } // Must have one or both of: digit-sequence, dot+digit-sequence var has_integer = false; var has_decimal = false; if (pos < value.len and std.ascii.isDigit(value[pos])) { has_integer = true; while (pos < value.len and std.ascii.isDigit(value[pos])) : (pos += 1) {} } if (pos < value.len and value[pos] == '.') { pos += 1; if (pos < value.len and std.ascii.isDigit(value[pos])) { has_decimal = true; while (pos < value.len and std.ascii.isDigit(value[pos])) : (pos += 1) {} } else { return false; // dot without trailing digits ("1.") } } if (!has_integer and !has_decimal) return false; // Optional exponent: (e|E) [+|-] digits if (pos < value.len and (value[pos] == 'e' or value[pos] == 'E')) { pos += 1; if (pos >= value.len) return false; if (value[pos] == '+' or value[pos] == '-') { pos += 1; if (pos >= value.len) return false; } if (!std.ascii.isDigit(value[pos])) return false; while (pos < value.len and std.ascii.isDigit(value[pos])) : (pos += 1) {} } if (pos != value.len) return false; // trailing junk // Grammar is valid; now check the parsed value doesn't overflow const f = std.fmt.parseFloat(f64, value) catch return false; return !std.math.isInf(f) and !std.math.isNan(f); } /// Validate a WHATWG "valid date string": YYYY-MM-DD fn isValidDate(value: []const u8) bool { // Minimum: 4-digit year + "-MM-DD" = 10 chars if (value.len < 10) return false; const year_len = value.len - 6; // "-MM-DD" is always 6 chars from end if (year_len < 4) return false; if (value[year_len] != '-' or value[year_len + 3] != '-') return false; const year = parseAllDigits(value[0..year_len]) orelse return false; if (year == 0) return false; const month = parseAllDigits(value[year_len + 1 .. year_len + 3]) orelse return false; if (month < 1 or month > 12) return false; const day = parseAllDigits(value[year_len + 4 .. year_len + 6]) orelse return false; if (day < 1 or day > daysInMonth(@intCast(year), @intCast(month))) return false; return true; } /// Validate a WHATWG "valid month string": YYYY-MM fn isValidMonth(value: []const u8) bool { if (value.len < 7) return false; const year_len = value.len - 3; // "-MM" is 3 chars from end if (year_len < 4) return false; if (value[year_len] != '-') return false; const year = parseAllDigits(value[0..year_len]) orelse return false; if (year == 0) return false; const month = parseAllDigits(value[year_len + 1 .. year_len + 3]) orelse return false; return month >= 1 and month <= 12; } /// Validate a WHATWG "valid week string": YYYY-Www fn isValidWeek(value: []const u8) bool { if (value.len < 8) return false; const year_len = value.len - 4; // "-Www" is 4 chars from end if (year_len < 4) return false; if (value[year_len] != '-' or value[year_len + 1] != 'W') return false; const year = parseAllDigits(value[0..year_len]) orelse return false; if (year == 0) return false; const week = parseAllDigits(value[year_len + 2 .. year_len + 4]) orelse return false; if (week < 1) return false; return week <= maxWeeksInYear(@intCast(year)); } /// Validate a WHATWG "valid time string": HH:MM[:SS[.s{1,3}]] fn isValidTime(value: []const u8) bool { if (value.len < 5) return false; if (value[2] != ':') return false; const hour = parseAllDigits(value[0..2]) orelse return false; if (hour > 23) return false; const minute = parseAllDigits(value[3..5]) orelse return false; if (minute > 59) return false; if (value.len == 5) return true; // Optional seconds if (value.len < 8 or value[5] != ':') return false; const second = parseAllDigits(value[6..8]) orelse return false; if (second > 59) return false; if (value.len == 8) return true; // Optional fractional seconds: 1-3 digits if (value[8] != '.') return false; const frac_len = value.len - 9; if (frac_len < 1 or frac_len > 3) return false; for (value[9..]) |c| { if (!std.ascii.isDigit(c)) return false; } return true; } /// Sanitize datetime-local: validate and normalize, or return "". /// Spec: if valid, normalize to "YYYY-MM-DDThh:mm" (shortest time form); /// otherwise set to "". fn sanitizeDatetimeLocal(comptime dupe: bool, value: []const u8, arena: std.mem.Allocator) ![]const u8 { if (value.len < 16) { return ""; } // Find separator (T or space) by scanning for it before a valid time start var sep_pos: ?usize = null; if (value.len >= 16) { for (0..value.len - 4) |i| { if ((value[i] == 'T' or value[i] == ' ') and i + 3 < value.len and std.ascii.isDigit(value[i + 1]) and std.ascii.isDigit(value[i + 2]) and value[i + 3] == ':') { sep_pos = i; break; } } } const sep = sep_pos orelse return ""; const date_part = value[0..sep]; const time_part = value[sep + 1 ..]; if (!isValidDate(date_part) or !isValidTime(time_part)) { return ""; } // Already normalized? (T separator and no trailing :00 or :00.000) if (value[sep] == 'T' and time_part.len == 5) { return if (comptime dupe) arena.dupe(u8, value) else value; } // Parse time components for normalization const second: u32 = if (time_part.len >= 8) (parseAllDigits(time_part[6..8]) orelse return "") else 0; var has_nonzero_frac = false; var frac_end: usize = 0; if (time_part.len > 9 and time_part[8] == '.') { for (time_part[9..], 0..) |c, fi| { if (c != '0') has_nonzero_frac = true; frac_end = fi + 1; } // Strip trailing zeros from fractional part while (frac_end > 0 and time_part[9 + frac_end - 1] == '0') : (frac_end -= 1) {} } // Build shortest time: HH:MM, or HH:MM:SS, or HH:MM:SS.fff const need_seconds = second != 0 or has_nonzero_frac; const time_len: usize = if (need_seconds) (if (frac_end > 0) 9 + frac_end else 8) else 5; const total_len = date_part.len + 1 + time_len; const result = try arena.alloc(u8, total_len); @memcpy(result[0..date_part.len], date_part); result[date_part.len] = 'T'; @memcpy(result[date_part.len + 1 ..][0..5], time_part[0..5]); if (need_seconds) { @memcpy(result[date_part.len + 6 ..][0..3], time_part[5..8]); if (frac_end > 0) { result[date_part.len + 9] = '.'; @memcpy(result[date_part.len + 10 ..][0..frac_end], time_part[9..][0..frac_end]); } } return result[0..total_len]; } /// Parse a slice that must be ALL ASCII digits into a u32. Returns null if any non-digit or empty. fn parseAllDigits(s: []const u8) ?u32 { if (s.len == 0) return null; var result: u32 = 0; for (s) |c| { if (!std.ascii.isDigit(c)) return null; result = result *% 10 +% (c - '0'); } return result; } fn isLeapYear(year: u32) bool { return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0); } fn daysInMonth(year: u32, month: u32) u32 { return switch (month) { 1, 3, 5, 7, 8, 10, 12 => 31, 4, 6, 9, 11 => 30, 2 => if (isLeapYear(year)) @as(u32, 29) else 28, else => 0, }; } /// ISO 8601: a year has 53 weeks if Jan 1 is Thursday, or Jan 1 is Wednesday and leap year. fn maxWeeksInYear(year: u32) u32 { // Gauss's algorithm for Jan 1 day-of-week // dow: 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat const y1 = year - 1; const dow = (1 + 5 * (y1 % 4) + 4 * (y1 % 100) + 6 * (y1 % 400)) % 7; if (dow == 4) return 53; // Jan 1 is Thursday if (dow == 3 and isLeapYear(year)) return 53; // Jan 1 is Wednesday + leap year return 52; } fn uncheckRadioGroup(self: *Input, page: *Page) !void { const element = self.asElement(); const name = element.getAttributeSafe(comptime .wrap("name")) orelse return; if (name.len == 0) { return; } const my_form = self.getForm(page); // Walk from the root of the tree containing this element // This handles both document-attached and orphaned elements const root = element.asNode().getRootNode(null); const TreeWalker = @import("../../TreeWalker.zig"); var walker = TreeWalker.Full.init(root, .{}); while (walker.next()) |node| { const other_element = node.is(Element) orelse continue; const other_input = other_element.is(Input) orelse continue; // Skip self if (other_input == self) { continue; } if (other_input._input_type != .radio) { continue; } const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue; if (!std.mem.eql(u8, name, other_name)) { continue; } // Check if same form context const other_form = other_input.getForm(page); if (my_form == null and other_form == null) { other_input._checked = false; continue; } if (my_form) |mf| { if (other_form) |of| { if (mf == of) { other_input._checked = false; } } } } } pub const JsApi = struct { pub const bridge = js.Bridge(Input); pub const Meta = struct { pub const name = "HTMLInputElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; /// Handles [LegacyNullToEmptyString]: null → "" per HTML spec. fn setValueFromJS(self: *Input, js_value: js.Value, page: *Page) !void { if (js_value.isNull()) { return self.setValue("", page); } return self.setValue(try js_value.toZig([]const u8), page); } pub const onselectionchange = bridge.accessor(Input.getOnSelectionChange, Input.setOnSelectionChange, .{}); pub const @"type" = bridge.accessor(Input.getType, Input.setType, .{}); pub const value = bridge.accessor(Input.getValue, setValueFromJS, .{ .dom_exception = true }); pub const defaultValue = bridge.accessor(Input.getDefaultValue, Input.setDefaultValue, .{}); pub const checked = bridge.accessor(Input.getChecked, Input.setChecked, .{}); pub const defaultChecked = bridge.accessor(Input.getDefaultChecked, Input.setDefaultChecked, .{}); pub const disabled = bridge.accessor(Input.getDisabled, Input.setDisabled, .{}); pub const name = bridge.accessor(Input.getName, Input.setName, .{}); pub const required = bridge.accessor(Input.getRequired, Input.setRequired, .{}); pub const accept = bridge.accessor(Input.getAccept, Input.setAccept, .{}); pub const readOnly = bridge.accessor(Input.getReadonly, Input.setReadonly, .{}); pub const alt = bridge.accessor(Input.getAlt, Input.setAlt, .{}); pub const maxLength = bridge.accessor(Input.getMaxLength, Input.setMaxLength, .{ .dom_exception = true }); pub const size = bridge.accessor(Input.getSize, Input.setSize, .{}); pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{}); pub const form = bridge.accessor(Input.getForm, null, .{}); pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{}); pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{}); pub const min = bridge.accessor(Input.getMin, Input.setMin, .{}); pub const max = bridge.accessor(Input.getMax, Input.setMax, .{}); pub const step = bridge.accessor(Input.getStep, Input.setStep, .{}); pub const multiple = bridge.accessor(Input.getMultiple, Input.setMultiple, .{}); pub const autocomplete = bridge.accessor(Input.getAutocomplete, Input.setAutocomplete, .{}); pub const willValidate = bridge.accessor(Input.getWillValidate, null, .{}); pub const select = bridge.function(Input.select, .{}); pub const selectionStart = bridge.accessor(Input.getSelectionStart, Input.setSelectionStart, .{}); pub const selectionEnd = bridge.accessor(Input.getSelectionEnd, Input.setSelectionEnd, .{}); pub const selectionDirection = bridge.accessor(Input.getSelectionDirection, null, .{}); pub const setSelectionRange = bridge.function(Input.setSelectionRange, .{ .dom_exception = true }); }; pub const Build = struct { pub fn created(node: *Node, page: *Page) !void { var self = node.as(Input); const element = self.asElement(); // Store initial values from attributes self._default_value = element.getAttributeSafe(comptime .wrap("value")); self._default_checked = element.getAttributeSafe(comptime .wrap("checked")) != null; self._checked = self._default_checked; self._input_type = if (element.getAttributeSafe(comptime .wrap("type"))) |type_attr| Type.fromString(type_attr) else .text; // Sanitize initial value per input type (e.g. date rejects "invalid-date"). if (self._default_value) |dv| { self._value = try self.sanitizeValue(false, dv, page); } else { self._value = null; } // If this is a checked radio button, uncheck others in its group if (self._checked and self._input_type == .radio) { try self.uncheckRadioGroup(page); } } pub fn attributeChange(element: *Element, name: String, value: String, page: *Page) !void { const attribute = std.meta.stringToEnum(enum { type, value, checked }, name.str()) orelse return; const self = element.as(Input); switch (attribute) { .type => { self._input_type = Type.fromString(value.str()); // Sanitize the current value according to the new type if (self._value) |current_value| { self._value = try self.sanitizeValue(false, current_value, page); // Apply default value for checkbox/radio if value is now empty if (self._value.?.len == 0 and (self._input_type == .checkbox or self._input_type == .radio)) { self._value = "on"; } } }, .value => self._default_value = try page.arena.dupe(u8, value.str()), .checked => { self._default_checked = true; // Only update checked state if it hasn't been manually modified if (!self._checked_dirty) { self._checked = true; // If setting a radio button to checked, uncheck others in the group if (self._input_type == .radio) { try self.uncheckRadioGroup(page); } } }, } } pub fn attributeRemove(element: *Element, name: String, _: *Page) !void { const attribute = std.meta.stringToEnum(enum { type, value, checked }, name.str()) orelse return; const self = element.as(Input); switch (attribute) { .type => self._input_type = .text, .value => self._default_value = null, .checked => { self._default_checked = false; // Only update checked state if it hasn't been manually modified if (!self._checked_dirty) { self._checked = false; } }, } } pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Page) !void { const source = source_element.as(Input); const clone = cloned_element.as(Input); // Copy runtime state from source to clone clone._value = source._value; clone._checked = source._checked; clone._checked_dirty = source._checked_dirty; clone._selection_direction = source._selection_direction; clone._selection_start = source._selection_start; clone._selection_end = source._selection_end; clone._indeterminate = source._indeterminate; } }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Input" { try testing.htmlRunner("element/html/input.html", .{}); try testing.htmlRunner("element/html/input_click.html", .{}); try testing.htmlRunner("element/html/input_radio.html", .{}); try testing.htmlRunner("element/html/input-attrs.html", .{}); } test "isValidFloatingPoint" { // Valid try testing.expect(isValidFloatingPoint("1")); try testing.expect(isValidFloatingPoint("0.5")); try testing.expect(isValidFloatingPoint("-1")); try testing.expect(isValidFloatingPoint("-0.5")); try testing.expect(isValidFloatingPoint("1e10")); try testing.expect(isValidFloatingPoint("1E10")); try testing.expect(isValidFloatingPoint("1e+10")); try testing.expect(isValidFloatingPoint("1e-10")); try testing.expect(isValidFloatingPoint("0.123")); try testing.expect(isValidFloatingPoint(".5")); // Invalid try testing.expect(!isValidFloatingPoint("")); try testing.expect(!isValidFloatingPoint("+1")); try testing.expect(!isValidFloatingPoint("1.")); try testing.expect(!isValidFloatingPoint("Infinity")); try testing.expect(!isValidFloatingPoint("NaN")); try testing.expect(!isValidFloatingPoint(" 1")); try testing.expect(!isValidFloatingPoint("1 ")); try testing.expect(!isValidFloatingPoint("1e")); try testing.expect(!isValidFloatingPoint("1e+")); try testing.expect(!isValidFloatingPoint("2e308")); // overflow } test "isValidDate" { try testing.expect(isValidDate("2024-01-01")); try testing.expect(isValidDate("2024-02-29")); // leap year try testing.expect(isValidDate("2024-12-31")); try testing.expect(isValidDate("10000-01-01")); // >4-digit year try testing.expect(!isValidDate("2024-02-30")); // invalid day try testing.expect(!isValidDate("2023-02-29")); // not leap year try testing.expect(!isValidDate("2024-13-01")); // invalid month try testing.expect(!isValidDate("2024-00-01")); // month 0 try testing.expect(!isValidDate("0000-01-01")); // year 0 try testing.expect(!isValidDate("2024-1-01")); // single-digit month try testing.expect(!isValidDate("")); try testing.expect(!isValidDate("not-a-date")); } test "isValidMonth" { try testing.expect(isValidMonth("2024-01")); try testing.expect(isValidMonth("2024-12")); try testing.expect(!isValidMonth("2024-00")); try testing.expect(!isValidMonth("2024-13")); try testing.expect(!isValidMonth("0000-01")); try testing.expect(!isValidMonth("")); } test "isValidWeek" { try testing.expect(isValidWeek("2024-W01")); try testing.expect(isValidWeek("2024-W52")); try testing.expect(isValidWeek("2020-W53")); // 2020 has 53 weeks try testing.expect(!isValidWeek("2024-W00")); try testing.expect(!isValidWeek("2024-W54")); try testing.expect(!isValidWeek("0000-W01")); try testing.expect(!isValidWeek("")); } test "isValidTime" { try testing.expect(isValidTime("00:00")); try testing.expect(isValidTime("23:59")); try testing.expect(isValidTime("12:30:45")); try testing.expect(isValidTime("12:30:45.1")); try testing.expect(isValidTime("12:30:45.12")); try testing.expect(isValidTime("12:30:45.123")); try testing.expect(!isValidTime("24:00")); try testing.expect(!isValidTime("12:60")); try testing.expect(!isValidTime("12:30:60")); try testing.expect(!isValidTime("12:30:45.1234")); // >3 frac digits try testing.expect(!isValidTime("12:30:45.")); // dot without digits try testing.expect(!isValidTime("")); } test "sanitizeDatetimeLocal" { const allocator = testing.allocator; // Already normalized — returns input slice, no allocation try testing.expectEqual("2024-01-01T12:30", try sanitizeDatetimeLocal(false, "2024-01-01T12:30", allocator)); // Space separator → T (allocates) { const result = try sanitizeDatetimeLocal(false, "2024-01-01 12:30", allocator); try testing.expectEqual("2024-01-01T12:30", result); allocator.free(result); } // Strip trailing :00 (allocates) { const result = try sanitizeDatetimeLocal(false, "2024-01-01T12:30:00", allocator); try testing.expectEqual("2024-01-01T12:30", result); allocator.free(result); } // Keep non-zero seconds (allocates) { const result = try sanitizeDatetimeLocal(false, "2024-01-01T12:30:45", allocator); try testing.expectEqual("2024-01-01T12:30:45", result); allocator.free(result); } // Keep fractional seconds, strip trailing zeros (allocates) { const result = try sanitizeDatetimeLocal(false, "2024-01-01T12:30:45.100", allocator); try testing.expectEqual("2024-01-01T12:30:45.1", result); allocator.free(result); } // Invalid → "" (no allocation) try testing.expectEqual("", try sanitizeDatetimeLocal(false, "not-a-datetime", allocator)); try testing.expectEqual("", try sanitizeDatetimeLocal(false, "", allocator)); } test "parseAllDigits" { try testing.expectEqual(@as(?u32, 0), parseAllDigits("0")); try testing.expectEqual(@as(?u32, 123), parseAllDigits("123")); try testing.expectEqual(@as(?u32, 2024), parseAllDigits("2024")); try testing.expectEqual(@as(?u32, null), parseAllDigits("")); try testing.expectEqual(@as(?u32, null), parseAllDigits("12a")); try testing.expectEqual(@as(?u32, null), parseAllDigits("abc")); } test "daysInMonth" { try testing.expectEqual(@as(u32, 31), daysInMonth(2024, 1)); try testing.expectEqual(@as(u32, 29), daysInMonth(2024, 2)); // leap try testing.expectEqual(@as(u32, 28), daysInMonth(2023, 2)); // non-leap try testing.expectEqual(@as(u32, 30), daysInMonth(2024, 4)); try testing.expectEqual(@as(u32, 29), daysInMonth(2000, 2)); // century leap try testing.expectEqual(@as(u32, 28), daysInMonth(1900, 2)); // century non-leap } test "maxWeeksInYear" { try testing.expectEqual(@as(u32, 52), maxWeeksInYear(2024)); try testing.expectEqual(@as(u32, 53), maxWeeksInYear(2020)); // Jan 1 = Wed + leap try testing.expectEqual(@as(u32, 53), maxWeeksInYear(2015)); // Jan 1 = Thu try testing.expectEqual(@as(u32, 52), maxWeeksInYear(2023)); } ================================================ FILE: src/browser/webapi/element/html/LI.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const LI = @This(); _proto: *HtmlElement, pub fn asElement(self: *LI) *Element { return self._proto._proto; } pub fn asNode(self: *LI) *Node { return self.asElement().asNode(); } pub fn getValue(self: *LI) i32 { const attr = self.asElement().getAttributeSafe(comptime .wrap("value")) orelse return 0; return std.fmt.parseInt(i32, attr, 10) catch 0; } pub fn setValue(self: *LI, value: i32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("value"), .wrap(str), page); } pub const JsApi = struct { pub const bridge = js.Bridge(LI); pub const Meta = struct { pub const name = "HTMLLIElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const value = bridge.accessor(LI.getValue, LI.setValue, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.LI" { try testing.htmlRunner("element/html/li.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Label.zig ================================================ const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TreeWalker = @import("../../TreeWalker.zig"); const Label = @This(); _proto: *HtmlElement, pub fn asElement(self: *Label) *Element { return self._proto._proto; } pub fn asNode(self: *Label) *Node { return self.asElement().asNode(); } pub fn getHtmlFor(self: *Label) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("for")) orelse ""; } pub fn setHtmlFor(self: *Label, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("for"), .wrap(value), page); } pub fn getControl(self: *Label, page: *Page) ?*Element { if (self.asElement().getAttributeSafe(comptime .wrap("for"))) |id| { const el = page.document.getElementById(id, page) orelse return null; if (!isLabelable(el)) { return null; } return el; } var tw = TreeWalker.FullExcludeSelf.Elements.init(self.asNode(), .{}); while (tw.next()) |el| { if (isLabelable(el)) { return el; } } return null; } fn isLabelable(el: *Element) bool { const html = el.is(HtmlElement) orelse return false; return switch (html._type) { .button, .meter, .output, .progress, .select, .textarea => true, .input => |input| input._input_type != .hidden, else => false, }; } pub const JsApi = struct { pub const bridge = js.Bridge(Label); pub const Meta = struct { pub const name = "HTMLLabelElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const htmlFor = bridge.accessor(Label.getHtmlFor, Label.setHtmlFor, .{}); pub const control = bridge.accessor(Label.getControl, null, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Label" { try testing.htmlRunner("element/html/label.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Legend.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Legend = @This(); _proto: *HtmlElement, pub fn asElement(self: *Legend) *Element { return self._proto._proto; } pub fn asNode(self: *Legend) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Legend); pub const Meta = struct { pub const name = "HTMLLegendElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Link.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const URL = @import("../../URL.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Link = @This(); _proto: *HtmlElement, pub fn asElement(self: *Link) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Link) *const Element { return self._proto._proto; } pub fn asNode(self: *Link) *Node { return self.asElement().asNode(); } pub fn getHref(self: *Link, page: *Page) ![]const u8 { const element = self.asElement(); const href = element.getAttributeSafe(comptime .wrap("href")) orelse return ""; if (href.len == 0) { return ""; } // Always resolve the href against the page URL return URL.resolve(page.call_arena, page.base(), href, .{ .encode = true }); } pub fn setHref(self: *Link, value: []const u8, page: *Page) !void { const element = self.asElement(); try element.setAttributeSafe(comptime .wrap("href"), .wrap(value), page); if (element.asNode().isConnected()) { try self.linkAddedCallback(page); } } pub fn getRel(self: *Link) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("rel")) orelse return ""; } pub fn setRel(self: *Link, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("rel"), .wrap(value), page); } pub fn getAs(self: *const Link) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("as")) orelse ""; } pub fn setAs(self: *Link, value: []const u8, page: *Page) !void { return self.asElement().setAttributeSafe(comptime .wrap("as"), .wrap(value), page); } pub fn getCrossOrigin(self: *const Link) ?[]const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("crossOrigin")); } pub fn setCrossOrigin(self: *Link, value: []const u8, page: *Page) !void { var normalized: []const u8 = "anonymous"; if (std.ascii.eqlIgnoreCase(value, "use-credentials")) { normalized = "use-credentials"; } return self.asElement().setAttributeSafe(comptime .wrap("crossOrigin"), .wrap(normalized), page); } pub fn linkAddedCallback(self: *Link, page: *Page) !void { // if we're planning on navigating to another page, don't trigger load event. if (page.isGoingAway()) { return; } const element = self.asElement(); const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return; const loadable_rels = std.StaticStringMap(void).initComptime(.{ .{ "stylesheet", {} }, .{ "preload", {} }, .{ "modulepreload", {} }, }); if (loadable_rels.has(rel) == false) { return; } const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; if (href.len == 0) { return; } try page._to_load.append(page.arena, self._proto); } pub const JsApi = struct { pub const bridge = js.Bridge(Link); pub const Meta = struct { pub const name = "HTMLLinkElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const as = bridge.accessor(Link.getAs, Link.setAs, .{}); pub const rel = bridge.accessor(Link.getRel, Link.setRel, .{}); pub const href = bridge.accessor(Link.getHref, Link.setHref, .{}); pub const crossOrigin = bridge.accessor(Link.getCrossOrigin, Link.setCrossOrigin, .{}); pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); fn _getRelList(self: *Link, page: *Page) !?*@import("../../collections.zig").DOMTokenList { const element = self.asElement(); // relList is only valid for HTML <link> elements, not SVG or MathML if (element._namespace != .html) { return null; } return element.getRelList(page); } }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Link" { try testing.htmlRunner("element/html/link.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Map.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Map = @This(); _proto: *HtmlElement, pub fn asElement(self: *Map) *Element { return self._proto._proto; } pub fn asNode(self: *Map) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Map); pub const Meta = struct { pub const name = "HTMLMapElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Media.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Event = @import("../../Event.zig"); pub const Audio = @import("Audio.zig"); pub const Video = @import("Video.zig"); const MediaError = @import("../../media/MediaError.zig"); const Media = @This(); pub const ReadyState = enum(u16) { HAVE_NOTHING = 0, HAVE_METADATA = 1, HAVE_CURRENT_DATA = 2, HAVE_FUTURE_DATA = 3, HAVE_ENOUGH_DATA = 4, }; pub const NetworkState = enum(u16) { NETWORK_EMPTY = 0, NETWORK_IDLE = 1, NETWORK_LOADING = 2, NETWORK_NO_SOURCE = 3, }; pub const Type = union(enum) { generic, audio: *Audio, video: *Video, }; _type: Type, _proto: *HtmlElement, _paused: bool = true, _current_time: f64 = 0, _volume: f64 = 1.0, _muted: bool = false, _playback_rate: f64 = 1.0, _ready_state: ReadyState = .HAVE_NOTHING, _network_state: NetworkState = .NETWORK_EMPTY, _error: ?*MediaError = null, pub fn asElement(self: *Media) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Media) *const Element { return self._proto._proto; } pub fn asNode(self: *Media) *Node { return self.asElement().asNode(); } pub fn is(self: *Media, comptime T: type) ?*T { const type_name = @typeName(T); switch (self._type) { .audio => |a| { if (T == *Audio) return a; if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Audio")) { return a; } }, .video => |v| { if (T == *Video) return v; if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Video")) { return v; } }, .generic => {}, } return null; } pub fn as(self: *Media, comptime T: type) *T { return self.is(T).?; } pub fn canPlayType(_: *const Media, mime_type: []const u8, page: *Page) []const u8 { const pos = std.mem.indexOfScalar(u8, mime_type, ';') orelse mime_type.len; const base_type = std.mem.trim(u8, mime_type[0..pos], &std.ascii.whitespace); if (base_type.len > page.buf.len) { return ""; } const lower = std.ascii.lowerString(&page.buf, base_type); if (isProbablySupported(lower)) { return "probably"; } if (isMaybeSupported(lower)) { return "maybe"; } return ""; } fn isProbablySupported(mime_type: []const u8) bool { if (std.mem.eql(u8, mime_type, "video/mp4")) return true; if (std.mem.eql(u8, mime_type, "video/webm")) return true; if (std.mem.eql(u8, mime_type, "audio/mp4")) return true; if (std.mem.eql(u8, mime_type, "audio/webm")) return true; if (std.mem.eql(u8, mime_type, "audio/mpeg")) return true; if (std.mem.eql(u8, mime_type, "audio/mp3")) return true; if (std.mem.eql(u8, mime_type, "audio/ogg")) return true; if (std.mem.eql(u8, mime_type, "video/ogg")) return true; if (std.mem.eql(u8, mime_type, "audio/wav")) return true; if (std.mem.eql(u8, mime_type, "audio/wave")) return true; if (std.mem.eql(u8, mime_type, "audio/x-wav")) return true; return false; } fn isMaybeSupported(mime_type: []const u8) bool { if (std.mem.eql(u8, mime_type, "audio/aac")) return true; if (std.mem.eql(u8, mime_type, "audio/x-m4a")) return true; if (std.mem.eql(u8, mime_type, "video/x-m4v")) return true; if (std.mem.eql(u8, mime_type, "audio/flac")) return true; return false; } pub fn play(self: *Media, page: *Page) !void { const was_paused = self._paused; self._paused = false; self._ready_state = .HAVE_ENOUGH_DATA; self._network_state = .NETWORK_IDLE; if (was_paused) { try self.dispatchEvent("play", page); try self.dispatchEvent("playing", page); } } pub fn pause(self: *Media, page: *Page) !void { if (!self._paused) { self._paused = true; try self.dispatchEvent("pause", page); } } pub fn load(self: *Media, page: *Page) !void { self._paused = true; self._current_time = 0; self._ready_state = .HAVE_NOTHING; self._network_state = .NETWORK_LOADING; self._error = null; try self.dispatchEvent("emptied", page); } fn dispatchEvent(self: *Media, name: []const u8, page: *Page) !void { const event = try Event.init(name, .{ .bubbles = false, .cancelable = false }, page); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } pub fn getPaused(self: *const Media) bool { return self._paused; } pub fn getCurrentTime(self: *const Media) f64 { return self._current_time; } pub fn getDuration(_: *const Media) f64 { return std.math.nan(f64); } pub fn getReadyState(self: *const Media) u16 { return @intFromEnum(self._ready_state); } pub fn getNetworkState(self: *const Media) u16 { return @intFromEnum(self._network_state); } pub fn getEnded(_: *const Media) bool { return false; } pub fn getSeeking(_: *const Media) bool { return false; } pub fn getError(self: *const Media) ?*MediaError { return self._error; } pub fn getVolume(self: *const Media) f64 { return self._volume; } pub fn setVolume(self: *Media, value: f64) void { self._volume = @max(0.0, @min(1.0, value)); } pub fn getMuted(self: *const Media) bool { return self._muted; } pub fn setMuted(self: *Media, value: bool) void { self._muted = value; } pub fn getPlaybackRate(self: *const Media) f64 { return self._playback_rate; } pub fn setPlaybackRate(self: *Media, value: f64) void { self._playback_rate = value; } pub fn setCurrentTime(self: *Media, value: f64) void { self._current_time = value; } pub fn getSrc(self: *const Media, page: *Page) ![]const u8 { const element = self.asConstElement(); const src = element.getAttributeSafe(comptime .wrap("src")) orelse return ""; if (src.len == 0) { return ""; } const URL = @import("../../URL.zig"); return URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }); } pub fn setSrc(self: *Media, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("src"), .wrap(value), page); } pub fn getAutoplay(self: *const Media) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("autoplay")) != null; } pub fn setAutoplay(self: *Media, value: bool, page: *Page) !void { if (value) { try self.asElement().setAttributeSafe(comptime .wrap("autoplay"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("autoplay"), page); } } pub fn getControls(self: *const Media) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("controls")) != null; } pub fn setControls(self: *Media, value: bool, page: *Page) !void { if (value) { try self.asElement().setAttributeSafe(comptime .wrap("controls"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("controls"), page); } } pub fn getLoop(self: *const Media) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("loop")) != null; } pub fn setLoop(self: *Media, value: bool, page: *Page) !void { if (value) { try self.asElement().setAttributeSafe(comptime .wrap("loop"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("loop"), page); } } pub fn getPreload(self: *const Media) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("preload")) orelse "auto"; } pub fn setPreload(self: *Media, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("preload"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Media); pub const Meta = struct { pub const name = "HTMLMediaElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const NETWORK_EMPTY = bridge.property(@intFromEnum(NetworkState.NETWORK_EMPTY), .{ .template = true }); pub const NETWORK_IDLE = bridge.property(@intFromEnum(NetworkState.NETWORK_IDLE), .{ .template = true }); pub const NETWORK_LOADING = bridge.property(@intFromEnum(NetworkState.NETWORK_LOADING), .{ .template = true }); pub const NETWORK_NO_SOURCE = bridge.property(@intFromEnum(NetworkState.NETWORK_NO_SOURCE), .{ .template = true }); pub const HAVE_NOTHING = bridge.property(@intFromEnum(ReadyState.HAVE_NOTHING), .{ .template = true }); pub const HAVE_METADATA = bridge.property(@intFromEnum(ReadyState.HAVE_METADATA), .{ .template = true }); pub const HAVE_CURRENT_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_CURRENT_DATA), .{ .template = true }); pub const HAVE_FUTURE_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_FUTURE_DATA), .{ .template = true }); pub const HAVE_ENOUGH_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_ENOUGH_DATA), .{ .template = true }); pub const src = bridge.accessor(Media.getSrc, Media.setSrc, .{}); pub const autoplay = bridge.accessor(Media.getAutoplay, Media.setAutoplay, .{}); pub const controls = bridge.accessor(Media.getControls, Media.setControls, .{}); pub const loop = bridge.accessor(Media.getLoop, Media.setLoop, .{}); pub const muted = bridge.accessor(Media.getMuted, Media.setMuted, .{}); pub const preload = bridge.accessor(Media.getPreload, Media.setPreload, .{}); pub const volume = bridge.accessor(Media.getVolume, Media.setVolume, .{}); pub const playbackRate = bridge.accessor(Media.getPlaybackRate, Media.setPlaybackRate, .{}); pub const currentTime = bridge.accessor(Media.getCurrentTime, Media.setCurrentTime, .{}); pub const duration = bridge.accessor(Media.getDuration, null, .{}); pub const paused = bridge.accessor(Media.getPaused, null, .{}); pub const ended = bridge.accessor(Media.getEnded, null, .{}); pub const seeking = bridge.accessor(Media.getSeeking, null, .{}); pub const readyState = bridge.accessor(Media.getReadyState, null, .{}); pub const networkState = bridge.accessor(Media.getNetworkState, null, .{}); pub const @"error" = bridge.accessor(Media.getError, null, .{}); pub const canPlayType = bridge.function(Media.canPlayType, .{}); pub const play = bridge.function(Media.play, .{}); pub const pause = bridge.function(Media.pause, .{}); pub const load = bridge.function(Media.load, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: Media" { try testing.htmlRunner("element/html/media.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Meta.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Meta = @This(); // Because we have a JsApi.Meta, "Meta" can be ambiguous in some scopes. // Create a different alias we can use when in such ambiguous cases. const MetaElement = Meta; _proto: *HtmlElement, pub fn asElement(self: *Meta) *Element { return self._proto._proto; } pub fn asNode(self: *Meta) *Node { return self.asElement().asNode(); } pub fn getName(self: *Meta) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse return ""; } pub fn setName(self: *Meta, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), page); } pub fn getHttpEquiv(self: *Meta) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("http-equiv")) orelse return ""; } pub fn setHttpEquiv(self: *Meta, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("http-equiv"), .wrap(value), page); } pub fn getContent(self: *Meta) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("content")) orelse return ""; } pub fn setContent(self: *Meta, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("content"), .wrap(value), page); } pub fn getMedia(self: *Meta) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("media")) orelse return ""; } pub fn setMedia(self: *Meta, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("media"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(MetaElement); pub const Meta = struct { pub const name = "HTMLMetaElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const name = bridge.accessor(MetaElement.getName, MetaElement.setName, .{}); pub const httpEquiv = bridge.accessor(MetaElement.getHttpEquiv, MetaElement.setHttpEquiv, .{}); pub const content = bridge.accessor(MetaElement.getContent, MetaElement.setContent, .{}); pub const media = bridge.accessor(MetaElement.getMedia, MetaElement.setMedia, .{}); }; ================================================ FILE: src/browser/webapi/element/html/Meter.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Meter = @This(); _proto: *HtmlElement, pub fn asElement(self: *Meter) *Element { return self._proto._proto; } pub fn asNode(self: *Meter) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Meter); pub const Meta = struct { pub const name = "HTMLMeterElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Mod.zig ================================================ const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Mod = @This(); _tag_name: String, _tag: Element.Tag, _proto: *HtmlElement, pub fn asElement(self: *Mod) *Element { return self._proto._proto; } pub fn asNode(self: *Mod) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Mod); pub const Meta = struct { pub const name = "HTMLModElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/OL.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const OL = @This(); _proto: *HtmlElement, pub fn asElement(self: *OL) *Element { return self._proto._proto; } pub fn asNode(self: *OL) *Node { return self.asElement().asNode(); } pub fn getStart(self: *OL) i32 { const attr = self.asElement().getAttributeSafe(comptime .wrap("start")) orelse return 1; return std.fmt.parseInt(i32, attr, 10) catch 1; } pub fn setStart(self: *OL, value: i32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("start"), .wrap(str), page); } pub fn getReversed(self: *OL) bool { return self.asElement().getAttributeSafe(comptime .wrap("reversed")) != null; } pub fn setReversed(self: *OL, value: bool, page: *Page) !void { if (value) { try self.asElement().setAttributeSafe(comptime .wrap("reversed"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("reversed"), page); } } pub fn getType(self: *OL) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("type")) orelse "1"; } pub fn setType(self: *OL, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(OL); pub const Meta = struct { pub const name = "HTMLOListElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const start = bridge.accessor(OL.getStart, OL.setStart, .{}); pub const reversed = bridge.accessor(OL.getReversed, OL.setReversed, .{}); pub const @"type" = bridge.accessor(OL.getType, OL.setType, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.OL" { try testing.htmlRunner("element/html/ol.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Object.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Object = @This(); _proto: *HtmlElement, pub fn asElement(self: *Object) *Element { return self._proto._proto; } pub fn asNode(self: *Object) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Object); pub const Meta = struct { pub const name = "HTMLObjectElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/OptGroup.zig ================================================ const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const OptGroup = @This(); _proto: *HtmlElement, pub fn asElement(self: *OptGroup) *Element { return self._proto._proto; } pub fn asNode(self: *OptGroup) *Node { return self.asElement().asNode(); } pub fn getDisabled(self: *OptGroup) bool { return self.asElement().getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn setDisabled(self: *OptGroup, value: bool, page: *Page) !void { if (value) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } pub fn getLabel(self: *OptGroup) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("label")) orelse ""; } pub fn setLabel(self: *OptGroup, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("label"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(OptGroup); pub const Meta = struct { pub const name = "HTMLOptGroupElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const disabled = bridge.accessor(OptGroup.getDisabled, OptGroup.setDisabled, .{}); pub const label = bridge.accessor(OptGroup.getLabel, OptGroup.setLabel, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.OptGroup" { try testing.htmlRunner("element/html/optgroup.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Option.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Option = @This(); _proto: *HtmlElement, _value: ?[]const u8 = null, _selected: bool = false, _default_selected: bool = false, _disabled: bool = false, pub fn asElement(self: *Option) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Option) *const Element { return self._proto._proto; } pub fn asNode(self: *Option) *Node { return self.asElement().asNode(); } pub fn getValue(self: *Option, page: *Page) []const u8 { // If value attribute exists, use that; otherwise use text content (stripped) if (self._value) |v| { return v; } const node = self.asNode(); const text = node.getTextContentAlloc(page.call_arena) catch return ""; return std.mem.trim(u8, text, &std.ascii.whitespace); } pub fn setValue(self: *Option, value: []const u8, page: *Page) !void { const owned = try page.dupeString(value); try self.asElement().setAttributeSafe(comptime .wrap("value"), .wrap(owned), page); self._value = owned; } pub fn getText(self: *const Option, page: *Page) []const u8 { const node: *Node = @constCast(self.asConstElement().asConstNode()); return node.getTextContentAlloc(page.call_arena) catch ""; } pub fn setText(self: *Option, value: []const u8, page: *Page) !void { try self.asNode().setTextContent(value, page); } pub fn getSelected(self: *const Option) bool { return self._selected; } pub fn setSelected(self: *Option, selected: bool, page: *Page) !void { // TODO: When setting selected=true, may need to unselect other options // in the parent <select> if it doesn't have multiple attribute self._selected = selected; page.domChanged(); } pub fn getDefaultSelected(self: *const Option) bool { return self._default_selected; } pub fn getDisabled(self: *const Option) bool { return self._disabled; } pub fn setDisabled(self: *Option, disabled: bool, page: *Page) !void { self._disabled = disabled; if (disabled) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } pub fn getName(self: *const Option) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Option, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Option); pub const Meta = struct { pub const name = "HTMLOptionElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const value = bridge.accessor(Option.getValue, Option.setValue, .{}); pub const text = bridge.accessor(Option.getText, Option.setText, .{}); pub const selected = bridge.accessor(Option.getSelected, Option.setSelected, .{}); pub const defaultSelected = bridge.accessor(Option.getDefaultSelected, null, .{}); pub const disabled = bridge.accessor(Option.getDisabled, Option.setDisabled, .{}); pub const name = bridge.accessor(Option.getName, Option.setName, .{}); }; pub const Build = struct { pub fn created(node: *Node, _: *Page) !void { var self = node.as(Option); const element = self.asElement(); // Check for value attribute self._value = element.getAttributeSafe(comptime .wrap("value")); // Check for selected attribute self._default_selected = element.getAttributeSafe(comptime .wrap("selected")) != null; self._selected = self._default_selected; // Check for disabled attribute self._disabled = element.getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn attributeChange(element: *Element, name: String, value: String, _: *Page) !void { const attribute = std.meta.stringToEnum(enum { value, selected }, name.str()) orelse return; const self = element.as(Option); switch (attribute) { .value => self._value = value.str(), .selected => { self._default_selected = true; self._selected = true; }, } } pub fn attributeRemove(element: *Element, name: String, _: *Page) !void { const attribute = std.meta.stringToEnum(enum { value, selected }, name.str()) orelse return; const self = element.as(Option); switch (attribute) { .value => self._value = null, .selected => { self._default_selected = false; self._selected = false; }, } } }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Option" { try testing.htmlRunner("element/html/option.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Output.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Output = @This(); _proto: *HtmlElement, pub fn asElement(self: *Output) *Element { return self._proto._proto; } pub fn asNode(self: *Output) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Output); pub const Meta = struct { pub const name = "HTMLOutputElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Paragraph.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Paragraph = @This(); _proto: *HtmlElement, pub fn asElement(self: *Paragraph) *Element { return self._proto._proto; } pub fn asNode(self: *Paragraph) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Paragraph); pub const Meta = struct { pub const name = "HTMLParagraphElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Param.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Param = @This(); _proto: *HtmlElement, pub fn asElement(self: *Param) *Element { return self._proto._proto; } pub fn asNode(self: *Param) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Param); pub const Meta = struct { pub const name = "HTMLParamElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Picture.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Picture = @This(); _proto: *HtmlElement, pub fn asElement(self: *Picture) *Element { return self._proto._proto; } pub fn asNode(self: *Picture) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Picture); pub const Meta = struct { pub const name = "HTMLPictureElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; const testing = @import("../../../../testing.zig"); test "WebApi: Picture" { try testing.htmlRunner("element/html/picture.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Pre.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Pre = @This(); _proto: *HtmlElement, pub fn asElement(self: *Pre) *Element { return self._proto._proto; } pub fn asNode(self: *Pre) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Pre); pub const Meta = struct { pub const name = "HTMLPreElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Progress.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Progress = @This(); _proto: *HtmlElement, pub fn asElement(self: *Progress) *Element { return self._proto._proto; } pub fn asNode(self: *Progress) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Progress); pub const Meta = struct { pub const name = "HTMLProgressElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Quote.zig ================================================ const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Quote = @This(); _tag_name: String, _tag: Element.Tag, _proto: *HtmlElement, pub fn asElement(self: *Quote) *Element { return self._proto._proto; } pub fn asNode(self: *Quote) *Node { return self.asElement().asNode(); } pub fn getCite(self: *Quote, page: *Page) ![]const u8 { const attr = self.asElement().getAttributeSafe(comptime .wrap("cite")) orelse return ""; if (attr.len == 0) return ""; const URL = @import("../../URL.zig"); return URL.resolve(page.call_arena, page.base(), attr, .{}); } pub fn setCite(self: *Quote, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("cite"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Quote); pub const Meta = struct { pub const name = "HTMLQuoteElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const cite = bridge.accessor(Quote.getCite, Quote.setCite, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Quote" { try testing.htmlRunner("element/html/quote.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Script.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const URL = @import("../../URL.zig"); const Script = @This(); _proto: *HtmlElement, _src: []const u8 = "", _executed: bool = false, // dynamic scripts are forced to be async by default _force_async: bool = true, pub fn asElement(self: *Script) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Script) *const Element { return self._proto._proto; } pub fn asNode(self: *Script) *Node { return self.asElement().asNode(); } pub fn getSrc(self: *const Script, page: *Page) ![]const u8 { if (self._src.len == 0) return ""; return try URL.resolve(page.call_arena, page.base(), self._src, .{ .encode = true }); } pub fn setSrc(self: *Script, src: []const u8, page: *Page) !void { const element = self.asElement(); try element.setAttributeSafe(comptime .wrap("src"), .wrap(src), page); self._src = element.getAttributeSafe(comptime .wrap("src")) orelse unreachable; if (element.asNode().isConnected()) { try page.scriptAddedCallback(false, self); } } pub fn getType(self: *const Script) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("type")) orelse ""; } pub fn setType(self: *Script, value: []const u8, page: *Page) !void { return self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(value), page); } pub fn getNonce(self: *const Script) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("nonce")) orelse ""; } pub fn setNonce(self: *Script, value: []const u8, page: *Page) !void { return self.asElement().setAttributeSafe(comptime .wrap("nonce"), .wrap(value), page); } pub fn getCharset(self: *const Script) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("charset")) orelse ""; } pub fn setCharset(self: *Script, value: []const u8, page: *Page) !void { return self.asElement().setAttributeSafe(comptime .wrap("charset"), .wrap(value), page); } pub fn getAsync(self: *const Script) bool { return self._force_async or self.asConstElement().getAttributeSafe(comptime .wrap("async")) != null; } pub fn setAsync(self: *Script, value: bool, page: *Page) !void { self._force_async = false; if (value) { try self.asElement().setAttributeSafe(comptime .wrap("async"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("async"), page); } } pub fn getDefer(self: *const Script) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("defer")) != null; } pub fn setDefer(self: *Script, value: bool, page: *Page) !void { if (value) { try self.asElement().setAttributeSafe(comptime .wrap("defer"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("defer"), page); } } pub fn getNoModule(self: *const Script) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("nomodule")) != null; } pub fn setInnerText(self: *Script, text: []const u8, page: *Page) !void { try self.asNode().setTextContent(text, page); } pub const JsApi = struct { pub const bridge = js.Bridge(Script); pub const Meta = struct { pub const name = "HTMLScriptElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{}); pub const @"defer" = bridge.accessor(Script.getDefer, Script.setDefer, .{}); pub const async = bridge.accessor(Script.getAsync, Script.setAsync, .{}); pub const @"type" = bridge.accessor(Script.getType, Script.setType, .{}); pub const nonce = bridge.accessor(Script.getNonce, Script.setNonce, .{}); pub const charset = bridge.accessor(Script.getCharset, Script.setCharset, .{}); pub const noModule = bridge.accessor(Script.getNoModule, null, .{}); pub const innerText = bridge.accessor(_innerText, Script.setInnerText, .{}); fn _innerText(self: *Script, page: *const Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.asNode().getTextContent(&buf.writer); return buf.written(); } pub const text = bridge.accessor(_text, Script.setInnerText, .{}); fn _text(self: *Script, page: *const Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.asNode().getChildTextContent(&buf.writer); return buf.written(); } }; pub const Build = struct { pub fn complete(node: *Node, _: *Page) !void { const self = node.as(Script); const element = self.asElement(); self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; } }; const testing = @import("../../../../testing.zig"); test "WebApi: Script" { try testing.htmlRunner("element/html/script", .{}); } ================================================ FILE: src/browser/webapi/element/html/Select.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const collections = @import("../../collections.zig"); const Form = @import("Form.zig"); pub const Option = @import("Option.zig"); const Select = @This(); _proto: *HtmlElement, _selected_index_set: bool = false, pub fn asElement(self: *Select) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Select) *const Element { return self._proto._proto; } pub fn asNode(self: *Select) *Node { return self.asElement().asNode(); } pub fn asConstNode(self: *const Select) *const Node { return self.asConstElement().asConstNode(); } pub fn getValue(self: *Select, page: *Page) []const u8 { // Return value of first selected option, or first option if none selected var first_option: ?*Option = null; var iter = self.asNode().childrenIterator(); while (iter.next()) |child| { const option = child.is(Option) orelse continue; if (option.getDisabled()) { continue; } if (option.getSelected()) { return option.getValue(page); } if (first_option == null) { first_option = option; } } // No explicitly selected option, return first option's value if (first_option) |opt| { return opt.getValue(page); } return ""; } pub fn setValue(self: *Select, value: []const u8, page: *Page) !void { // Find option with matching value and select it // Note: This updates the current state (_selected), not the default state (attribute) // Setting value always deselects all others, even for multiple selects var iter = self.asNode().childrenIterator(); while (iter.next()) |child| { const option = child.is(Option) orelse continue; option._selected = std.mem.eql(u8, option.getValue(page), value); } } pub fn getSelectedIndex(self: *Select) i32 { var index: i32 = 0; var has_options = false; var iter = self.asNode().childrenIterator(); while (iter.next()) |child| { const option = child.is(Option) orelse continue; has_options = true; if (option.getSelected()) { return index; } index += 1; } // If selectedIndex was explicitly set and no option is selected, return -1 // If selectedIndex was never set, return 0 (first option implicitly selected) if we have options if (self._selected_index_set) { return -1; } return if (has_options) 0 else -1; } pub fn setSelectedIndex(self: *Select, index: i32) !void { // Mark that selectedIndex has been explicitly set self._selected_index_set = true; // Select option at given index // Note: This updates the current state (_selected), not the default state (attribute) const is_multiple = self.getMultiple(); var current_index: i32 = 0; var iter = self.asNode().childrenIterator(); while (iter.next()) |child| { const option = child.is(Option) orelse continue; if (current_index == index) { option._selected = true; } else if (!is_multiple) { // Only deselect others if not multiple option._selected = false; } current_index += 1; } } pub fn getMultiple(self: *const Select) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("multiple")) != null; } pub fn setMultiple(self: *Select, multiple: bool, page: *Page) !void { if (multiple) { try self.asElement().setAttributeSafe(comptime .wrap("multiple"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("multiple"), page); } } pub fn getDisabled(self: *const Select) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn setDisabled(self: *Select, disabled: bool, page: *Page) !void { if (disabled) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } pub fn getName(self: *const Select) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Select, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), page); } pub fn getSize(self: *const Select) u32 { const s = self.asConstElement().getAttributeSafe(comptime .wrap("size")) orelse return 0; const trimmed = std.mem.trimLeft(u8, s, &std.ascii.whitespace); var end: usize = 0; for (trimmed) |b| { if (!std.ascii.isDigit(b)) { break; } end += 1; } if (end == 0) { return 0; } return std.fmt.parseInt(u32, trimmed[0..end], 10) catch 0; } pub fn setSize(self: *Select, size: u32, page: *Page) !void { const size_string = try std.fmt.allocPrint(page.call_arena, "{d}", .{size}); try self.asElement().setAttributeSafe(comptime .wrap("size"), .wrap(size_string), page); } pub fn getRequired(self: *const Select) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("required")) != null; } pub fn setRequired(self: *Select, required: bool, page: *Page) !void { if (required) { try self.asElement().setAttributeSafe(comptime .wrap("required"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("required"), page); } } pub fn getOptions(self: *Select, page: *Page) !*collections.HTMLOptionsCollection { // For options, we use the child_tag mode to filter only <option> elements const node_live = collections.NodeLive(.child_tag).init(self.asNode(), .option, page); const html_collection = try node_live.runtimeGenericWrap(page); // Create and return HTMLOptionsCollection return page._factory.create(collections.HTMLOptionsCollection{ ._proto = html_collection, ._select = self, }); } pub fn getLength(self: *Select) u32 { var i: u32 = 0; var it = self.asNode().childrenIterator(); while (it.next()) |child| { if (child.is(Option) != null) { i += 1; } } return i; } pub fn getSelectedOptions(self: *Select, page: *Page) !collections.NodeLive(.selected_options) { return collections.NodeLive(.selected_options).init(self.asNode(), {}, page); } pub fn getForm(self: *Select, page: *Page) ?*Form { const element = self.asElement(); // If form attribute exists, ONLY use that (even if it references nothing) if (element.getAttributeSafe(comptime .wrap("form"))) |form_id| { if (page.document.getElementById(form_id, page)) |form_element| { return form_element.is(Form); } // form attribute present but invalid - no form owner return null; } // No form attribute - traverse ancestors looking for a <form> var node = element.asNode()._parent; while (node) |n| { if (n.is(Element.Html.Form)) |form| { return form; } node = n._parent; } return null; } pub const JsApi = struct { pub const bridge = js.Bridge(Select); pub const Meta = struct { pub const name = "HTMLSelectElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const value = bridge.accessor(Select.getValue, Select.setValue, .{}); pub const selectedIndex = bridge.accessor(Select.getSelectedIndex, Select.setSelectedIndex, .{}); pub const multiple = bridge.accessor(Select.getMultiple, Select.setMultiple, .{}); pub const disabled = bridge.accessor(Select.getDisabled, Select.setDisabled, .{}); pub const name = bridge.accessor(Select.getName, Select.setName, .{}); pub const required = bridge.accessor(Select.getRequired, Select.setRequired, .{}); pub const options = bridge.accessor(Select.getOptions, null, .{}); pub const selectedOptions = bridge.accessor(Select.getSelectedOptions, null, .{}); pub const form = bridge.accessor(Select.getForm, null, .{}); pub const size = bridge.accessor(Select.getSize, Select.setSize, .{}); pub const length = bridge.accessor(Select.getLength, null, .{}); }; pub const Build = struct { pub fn created(_: *Node, _: *Page) !void { // No initialization needed - disabled is lazy from attribute } }; const std = @import("std"); const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Select" { try testing.htmlRunner("element/html/select.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Slot.zig ================================================ const std = @import("std"); const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const ShadowRoot = @import("../../ShadowRoot.zig"); const Slot = @This(); _proto: *HtmlElement, pub fn asElement(self: *Slot) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Slot) *const Element { return self._proto._proto; } pub fn asNode(self: *Slot) *Node { return self.asElement().asNode(); } pub fn getName(self: *const Slot) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *Slot, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), page); } const AssignedNodesOptions = struct { flatten: bool = false, }; pub fn assignedNodes(self: *Slot, opts_: ?AssignedNodesOptions, page: *Page) ![]const *Node { const opts = opts_ orelse AssignedNodesOptions{}; var nodes: std.ArrayList(*Node) = .empty; try self.collectAssignedNodes(false, &nodes, opts, page); return nodes.items; } pub fn assignedElements(self: *Slot, opts_: ?AssignedNodesOptions, page: *Page) ![]const *Element { const opts = opts_ orelse AssignedNodesOptions{}; var elements: std.ArrayList(*Element) = .empty; try self.collectAssignedNodes(true, &elements, opts, page); return elements.items; } fn CollectionType(comptime elements: bool) type { return if (elements) *std.ArrayList(*Element) else *std.ArrayList(*Node); } fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionType(elements), opts: AssignedNodesOptions, page: *Page) !void { // Find the shadow root this slot belongs to const shadow_root = self.findShadowRoot() orelse return; const slot_name = self.getName(); const allocator = page.call_arena; const host = shadow_root.getHost(); const initial_count = coll.items.len; var it = host.asNode().childrenIterator(); while (it.next()) |child| { if (!isAssignedToSlot(child, slot_name)) { continue; } if (opts.flatten) { if (child.is(Slot)) |child_slot| { // Only flatten if the child slot is actually in a shadow tree if (child_slot.findShadowRoot()) |_| { try child_slot.collectAssignedNodes(elements, coll, opts, page); continue; } // Otherwise, treat it as a regular element and fall through } } if (comptime elements) { if (child.is(Element)) |el| { try coll.append(allocator, el); } } else { try coll.append(allocator, child); } } // If flatten is true and no assigned nodes were found, return fallback content if (opts.flatten and coll.items.len == initial_count) { var child_it = self.asNode().childrenIterator(); while (child_it.next()) |child| { if (comptime elements) { if (child.is(Element)) |el| { try coll.append(allocator, el); } } else { try coll.append(allocator, child); } } } } pub fn assign(self: *Slot, nodes: []const *Node) void { // Imperative slot assignment API // This would require storing manually assigned nodes // For now, this is a placeholder for the API _ = self; _ = nodes; // let's see if this is ever actually used log.warn(.not_implemented, "Slot.assign", .{}); } fn findShadowRoot(self: *Slot) ?*ShadowRoot { // Walk up the parent chain to find the shadow root var parent = self.asNode()._parent; while (parent) |p| { if (p.is(ShadowRoot)) |shadow_root| { return shadow_root; } parent = p._parent; } return null; } fn isAssignedToSlot(node: *Node, slot_name: []const u8) bool { // Check if a node should be assigned to a slot with the given name if (node.is(Element)) |element| { // Get the slot attribute from the element const node_slot = element.getAttributeSafe(comptime .wrap("slot")) orelse ""; // Match if: // - Both are empty (default slot) // - They match exactly return std.mem.eql(u8, node_slot, slot_name); } // Text nodes, comments, etc. are only assigned to the default slot // (when they have no preceding/following element siblings with slot attributes) // For simplicity, text nodes go to default slot if slot_name is empty return slot_name.len == 0; } pub const JsApi = struct { pub const bridge = js.Bridge(Slot); pub const Meta = struct { pub const name = "HTMLSlotElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const name = bridge.accessor(Slot.getName, Slot.setName, .{}); pub const assignedNodes = bridge.function(Slot.assignedNodes, .{}); pub const assignedElements = bridge.function(Slot.assignedElements, .{}); pub const assign = bridge.function(Slot.assign, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTMLSlotElement" { try testing.htmlRunner("element/html/slot.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Source.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Source = @This(); _proto: *HtmlElement, pub fn asElement(self: *Source) *Element { return self._proto._proto; } pub fn asNode(self: *Source) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Source); pub const Meta = struct { pub const name = "HTMLSourceElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Span.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Span = @This(); _proto: *HtmlElement, pub fn asElement(self: *Span) *Element { return self._proto._proto; } pub fn asNode(self: *Span) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Span); pub const Meta = struct { pub const name = "HTMLSpanElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Style.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Style = @This(); _proto: *HtmlElement, _sheet: ?*CSSStyleSheet = null, pub fn asElement(self: *Style) *Element { return self._proto._proto; } pub fn asConstElement(self: *const Style) *const Element { return self._proto._proto; } pub fn asNode(self: *Style) *Node { return self.asElement().asNode(); } // Attribute-backed properties pub fn getBlocking(self: *const Style) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("blocking")) orelse ""; } pub fn setBlocking(self: *Style, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("blocking"), .wrap(value), page); } pub fn getMedia(self: *const Style) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("media")) orelse ""; } pub fn setMedia(self: *Style, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("media"), .wrap(value), page); } pub fn getType(self: *const Style) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("type")) orelse "text/css"; } pub fn setType(self: *Style, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(value), page); } pub fn getDisabled(self: *const Style) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn setDisabled(self: *Style, disabled: bool, page: *Page) !void { if (disabled) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } const CSSStyleSheet = @import("../../css/CSSStyleSheet.zig"); pub fn getSheet(self: *Style, page: *Page) !?*CSSStyleSheet { // Per spec, sheet is null for disconnected elements or non-CSS types. // Valid types: absent (defaults to "text/css"), empty string, or // case-insensitive match for "text/css". if (!self.asNode().isConnected()) { self._sheet = null; return null; } const t = self.getType(); if (t.len != 0 and !std.ascii.eqlIgnoreCase(t, "text/css")) { self._sheet = null; return null; } if (self._sheet) |sheet| return sheet; const sheet = try CSSStyleSheet.initWithOwner(self.asElement(), page); self._sheet = sheet; return sheet; } pub fn styleAddedCallback(self: *Style, page: *Page) !void { // if we're planning on navigating to another page, don't trigger load event. if (page.isGoingAway()) { return; } try page._to_load.append(page.arena, self._proto); } pub const JsApi = struct { pub const bridge = js.Bridge(Style); pub const Meta = struct { pub const name = "HTMLStyleElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const blocking = bridge.accessor(Style.getBlocking, Style.setBlocking, .{}); pub const media = bridge.accessor(Style.getMedia, Style.setMedia, .{}); pub const @"type" = bridge.accessor(Style.getType, Style.setType, .{}); pub const disabled = bridge.accessor(Style.getDisabled, Style.setDisabled, .{}); pub const sheet = bridge.accessor(Style.getSheet, null, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: Style" { try testing.htmlRunner("element/html/style.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Table.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Table = @This(); _proto: *HtmlElement, pub fn asElement(self: *Table) *Element { return self._proto._proto; } pub fn asNode(self: *Table) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Table); pub const Meta = struct { pub const name = "HTMLTableElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/TableCaption.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TableCaption = @This(); _proto: *HtmlElement, pub fn asElement(self: *TableCaption) *Element { return self._proto._proto; } pub fn asNode(self: *TableCaption) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(TableCaption); pub const Meta = struct { pub const name = "HTMLTableCaptionElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/TableCell.zig ================================================ const std = @import("std"); const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TableCell = @This(); _tag_name: String, _tag: Element.Tag, _proto: *HtmlElement, pub fn asElement(self: *TableCell) *Element { return self._proto._proto; } pub fn asNode(self: *TableCell) *Node { return self.asElement().asNode(); } pub fn getColSpan(self: *TableCell) u32 { const attr = self.asElement().getAttributeSafe(comptime .wrap("colspan")) orelse return 1; const v = std.fmt.parseUnsigned(u32, attr, 10) catch return 1; if (v == 0) return 1; return @min(v, 1000); } pub fn setColSpan(self: *TableCell, value: u32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("colspan"), .wrap(str), page); } pub fn getRowSpan(self: *TableCell) u32 { const attr = self.asElement().getAttributeSafe(comptime .wrap("rowspan")) orelse return 1; const v = std.fmt.parseUnsigned(u32, attr, 10) catch return 1; return @min(v, 65534); } pub fn setRowSpan(self: *TableCell, value: u32, page: *Page) !void { const str = try std.fmt.allocPrint(page.call_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("rowspan"), .wrap(str), page); } pub const JsApi = struct { pub const bridge = js.Bridge(TableCell); pub const Meta = struct { pub const name = "HTMLTableCellElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const colSpan = bridge.accessor(TableCell.getColSpan, TableCell.setColSpan, .{}); pub const rowSpan = bridge.accessor(TableCell.getRowSpan, TableCell.setRowSpan, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.TableCell" { try testing.htmlRunner("element/html/tablecell.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/TableCol.zig ================================================ const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TableCol = @This(); _tag_name: String, _tag: Element.Tag, _proto: *HtmlElement, pub fn asElement(self: *TableCol) *Element { return self._proto._proto; } pub fn asNode(self: *TableCol) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(TableCol); pub const Meta = struct { pub const name = "HTMLTableColElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/TableRow.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TableRow = @This(); _proto: *HtmlElement, pub fn asElement(self: *TableRow) *Element { return self._proto._proto; } pub fn asNode(self: *TableRow) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(TableRow); pub const Meta = struct { pub const name = "HTMLTableRowElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/TableSection.zig ================================================ const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TableSection = @This(); _tag_name: String, _tag: Element.Tag, _proto: *HtmlElement, pub fn asElement(self: *TableSection) *Element { return self._proto._proto; } pub fn asNode(self: *TableSection) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(TableSection); pub const Meta = struct { pub const name = "HTMLTableSectionElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Template.zig ================================================ const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const DocumentFragment = @import("../../DocumentFragment.zig"); const Template = @This(); _proto: *HtmlElement, _content: *DocumentFragment, pub fn asElement(self: *Template) *Element { return self._proto._proto; } pub fn asNode(self: *Template) *Node { return self.asElement().asNode(); } pub fn getContent(self: *Template) *DocumentFragment { return self._content; } pub fn setInnerHTML(self: *Template, html: []const u8, page: *Page) !void { return self._content.setInnerHTML(html, page); } pub fn getOuterHTML(self: *Template, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../../../dump.zig"); const el = self.asElement(); try el.format(writer); try dump.children(self._content.asNode(), .{ .shadow = .skip }, writer, page); try writer.writeAll("</"); try writer.writeAll(el.getTagNameDump()); try writer.writeByte('>'); } pub const JsApi = struct { pub const bridge = js.Bridge(Template); pub const Meta = struct { pub const name = "HTMLTemplateElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const content = bridge.accessor(Template.getContent, null, .{}); pub const innerHTML = bridge.accessor(_getInnerHTML, Template.setInnerHTML, .{}); pub const outerHTML = bridge.accessor(_getOuterHTML, null, .{}); fn _getInnerHTML(self: *Template, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self._content.getInnerHTML(&buf.writer, page); return buf.written(); } fn _getOuterHTML(self: *Template, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.getOuterHTML(&buf.writer, page); return buf.written(); } }; pub const Build = struct { pub fn created(node: *Node, page: *Page) !void { const self = node.as(Template); // Create the template content DocumentFragment self._content = try DocumentFragment.init(page); } }; const testing = @import("../../../../testing.zig"); test "WebApi: Template" { try testing.htmlRunner("element/html/template.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/TextArea.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); const Selection = @import("../../Selection.zig"); const Event = @import("../../Event.zig"); const InputEvent = @import("../../event/InputEvent.zig"); const TextArea = @This(); _proto: *HtmlElement, _value: ?[]const u8 = null, _selection_start: u32 = 0, _selection_end: u32 = 0, _selection_direction: Selection.SelectionDirection = .none, _on_selectionchange: ?js.Function.Global = null, pub fn getOnSelectionChange(self: *TextArea) ?js.Function.Global { return self._on_selectionchange; } pub fn setOnSelectionChange(self: *TextArea, listener: ?js.Function) !void { if (listener) |listen| { self._on_selectionchange = try listen.persistWithThis(self); } else { self._on_selectionchange = null; } } fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) !void { const event = try Event.init("selectionchange", .{ .bubbles = true }, page); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } fn dispatchInputEvent(self: *TextArea, data: ?[]const u8, input_type: []const u8, page: *Page) !void { const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page); try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent()); } pub fn asElement(self: *TextArea) *Element { return self._proto._proto; } pub fn asConstElement(self: *const TextArea) *const Element { return self._proto._proto; } pub fn asNode(self: *TextArea) *Node { return self.asElement().asNode(); } pub fn asConstNode(self: *const TextArea) *const Node { return self.asConstElement().asConstNode(); } pub fn getValue(self: *const TextArea) []const u8 { return self._value orelse self.getDefaultValue(); } pub fn setValue(self: *TextArea, value: []const u8, page: *Page) !void { const owned = try page.arena.dupe(u8, value); self._value = owned; } pub fn getDefaultValue(self: *const TextArea) []const u8 { const node = self.asConstNode(); if (node.firstChild()) |child| { if (child.is(Node.CData.Text)) |txt| { return txt.getWholeText(); } } return ""; } pub fn setDefaultValue(self: *TextArea, value: []const u8, page: *Page) !void { const node = self.asNode(); if (node.firstChild()) |child| { if (child.is(Node.CData.Text)) |txt| { txt._proto._data = try page.dupeSSO(value); return; } } // No text child exists, create one const text_node = try page.createTextNode(value); _ = try node.appendChild(text_node, page); } pub fn getDisabled(self: *const TextArea) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("disabled")) != null; } pub fn setDisabled(self: *TextArea, disabled: bool, page: *Page) !void { if (disabled) { try self.asElement().setAttributeSafe(comptime .wrap("disabled"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("disabled"), page); } } pub fn getName(self: *const TextArea) []const u8 { return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } pub fn setName(self: *TextArea, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), page); } pub fn getRequired(self: *const TextArea) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("required")) != null; } pub fn setRequired(self: *TextArea, required: bool, page: *Page) !void { if (required) { try self.asElement().setAttributeSafe(comptime .wrap("required"), .wrap(""), page); } else { try self.asElement().removeAttribute(comptime .wrap("required"), page); } } pub fn select(self: *TextArea, page: *Page) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; try self.setSelectionRange(0, len, null, page); const event = try Event.init("select", .{ .bubbles = true }, page); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; fn howSelected(self: *const TextArea) HowSelected { const value = self._value orelse return .none; if (self._selection_start == self._selection_end) return .none; if (self._selection_start == 0 and self._selection_end == value.len) return .full; return .{ .partial = .{ self._selection_start, self._selection_end } }; } pub fn innerInsert(self: *TextArea, str: []const u8, page: *Page) !void { const arena = page.arena; switch (self.howSelected()) { .full => { // if the text area is fully selected, replace the content. const new_value = try arena.dupe(u8, str); try self.setValue(new_value, page); self._selection_start = @intCast(new_value.len); self._selection_end = @intCast(new_value.len); self._selection_direction = .none; try self.dispatchSelectionChangeEvent(page); }, .partial => |range| { // if the text area is partially selected, replace the selected content. const current_value = self.getValue(); const before = current_value[0..range[0]]; const remaining = current_value[range[1]..]; const new_value = try std.mem.concat( arena, u8, &.{ before, str, remaining }, ); try self.setValue(new_value, page); const new_pos = range[0] + str.len; self._selection_start = @intCast(new_pos); self._selection_end = @intCast(new_pos); self._selection_direction = .none; try self.dispatchSelectionChangeEvent(page); }, .none => { // if the text area is not selected, just insert at cursor. const current_value = self.getValue(); const new_value = try std.mem.concat(arena, u8, &.{ current_value, str }); try self.setValue(new_value, page); }, } try self.dispatchInputEvent(str, "insertText", page); } pub fn getSelectionDirection(self: *const TextArea) []const u8 { return @tagName(self._selection_direction); } pub fn getSelectionStart(self: *const TextArea) u32 { return self._selection_start; } pub fn setSelectionStart(self: *TextArea, value: u32, page: *Page) !void { self._selection_start = value; try self.dispatchSelectionChangeEvent(page); } pub fn getSelectionEnd(self: *const TextArea) u32 { return self._selection_end; } pub fn setSelectionEnd(self: *TextArea, value: u32, page: *Page) !void { self._selection_end = value; try self.dispatchSelectionChangeEvent(page); } pub fn setSelectionRange( self: *TextArea, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8, page: *Page, ) !void { const direction = blk: { if (selection_dir) |sd| { break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none; } else break :blk .none; }; const value = self._value orelse { self._selection_start = 0; self._selection_end = 0; self._selection_direction = .none; return; }; const len_u32: u32 = @intCast(value.len); var start: u32 = if (selection_start > len_u32) len_u32 else selection_start; const end: u32 = if (selection_end > len_u32) len_u32 else selection_end; // If end is less than start, both are equal to end. if (end < start) { start = end; } self._selection_direction = direction; self._selection_start = start; self._selection_end = end; try self.dispatchSelectionChangeEvent(page); } pub fn getForm(self: *TextArea, page: *Page) ?*Form { const element = self.asElement(); // If form attribute exists, ONLY use that (even if it references nothing) if (element.getAttributeSafe(comptime .wrap("form"))) |form_id| { if (page.document.getElementById(form_id, page)) |form_element| { return form_element.is(Form); } // form attribute present but invalid - no form owner return null; } // No form attribute - traverse ancestors looking for a <form> var node = element.asNode()._parent; while (node) |n| { if (n.is(Element.Html.Form)) |form| { return form; } node = n._parent; } return null; } pub const JsApi = struct { pub const bridge = js.Bridge(TextArea); pub const Meta = struct { pub const name = "HTMLTextAreaElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const onselectionchange = bridge.accessor(TextArea.getOnSelectionChange, TextArea.setOnSelectionChange, .{}); pub const value = bridge.accessor(TextArea.getValue, TextArea.setValue, .{}); pub const defaultValue = bridge.accessor(TextArea.getDefaultValue, TextArea.setDefaultValue, .{}); pub const disabled = bridge.accessor(TextArea.getDisabled, TextArea.setDisabled, .{}); pub const name = bridge.accessor(TextArea.getName, TextArea.setName, .{}); pub const required = bridge.accessor(TextArea.getRequired, TextArea.setRequired, .{}); pub const form = bridge.accessor(TextArea.getForm, null, .{}); pub const select = bridge.function(TextArea.select, .{}); pub const selectionStart = bridge.accessor(TextArea.getSelectionStart, TextArea.setSelectionStart, .{}); pub const selectionEnd = bridge.accessor(TextArea.getSelectionEnd, TextArea.setSelectionEnd, .{}); pub const selectionDirection = bridge.accessor(TextArea.getSelectionDirection, null, .{}); pub const setSelectionRange = bridge.function(TextArea.setSelectionRange, .{ .dom_exception = true }); }; pub const Build = struct { pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Page) !void { const source = source_element.as(TextArea); const clone = cloned_element.as(TextArea); clone._value = source._value; } }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.TextArea" { try testing.htmlRunner("element/html/textarea.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Time.zig ================================================ const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Time = @This(); _proto: *HtmlElement, pub fn asElement(self: *Time) *Element { return self._proto._proto; } pub fn asNode(self: *Time) *Node { return self.asElement().asNode(); } pub fn getDateTime(self: *Time) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("datetime")) orelse ""; } pub fn setDateTime(self: *Time, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("datetime"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Time); pub const Meta = struct { pub const name = "HTMLTimeElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const dateTime = bridge.accessor(Time.getDateTime, Time.setDateTime, .{}); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Time" { try testing.htmlRunner("element/html/time.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/Title.zig ================================================ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Title = @This(); _proto: *HtmlElement, pub fn asElement(self: *Title) *Element { return self._proto._proto; } pub fn asNode(self: *Title) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Title); pub const Meta = struct { pub const name = "HTMLTitleElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Track.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../../js/js.zig"); const String = @import("../../../../string.zig").String; const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Track = @This(); _proto: *HtmlElement, _kind: String, _ready_state: ReadyState, const ReadyState = enum(u8) { none, loading, loaded, @"error" }; pub fn asElement(self: *Track) *Element { return self._proto._proto; } pub fn asNode(self: *Track) *Node { return self.asElement().asNode(); } pub fn setKind(self: *Track, maybe_kind: ?String) void { const kind = maybe_kind orelse { self._kind = comptime .wrap("metadata"); return; }; // Special case, for some reason, FF does this case-insensitive. if (std.ascii.eqlIgnoreCase(kind.str(), "subtitles")) { self._kind = comptime .wrap("subtitles"); return; } if (kind.eql(comptime .wrap("captions"))) { self._kind = comptime .wrap("captions"); return; } if (kind.eql(comptime .wrap("descriptions"))) { self._kind = comptime .wrap("descriptions"); return; } if (kind.eql(comptime .wrap("chapters"))) { self._kind = comptime .wrap("chapters"); return; } // Anything else must be considered as `metadata`. self._kind = comptime .wrap("metadata"); } pub fn getKind(self: *const Track) String { return self._kind; } pub const JsApi = struct { pub const bridge = js.Bridge(Track); pub const Meta = struct { pub const name = "HTMLTrackElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const kind = bridge.accessor(Track.getKind, Track.setKind, .{}); pub const NONE = bridge.property(@as(u16, @intFromEnum(ReadyState.none)), .{ .template = true }); pub const LOADING = bridge.property(@as(u16, @intFromEnum(ReadyState.loading)), .{ .template = true }); pub const LOADED = bridge.property(@as(u16, @intFromEnum(ReadyState.loaded)), .{ .template = true }); pub const ERROR = bridge.property(@as(u16, @intFromEnum(ReadyState.@"error")), .{ .template = true }); }; const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Track" { try testing.htmlRunner("element/html/track.html", .{}); } ================================================ FILE: src/browser/webapi/element/html/UL.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const UL = @This(); _proto: *HtmlElement, pub fn asElement(self: *UL) *Element { return self._proto._proto; } pub fn asNode(self: *UL) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(UL); pub const Meta = struct { pub const name = "HTMLUListElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Unknown.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const String = @import("../../../../string.zig").String; const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Unknown = @This(); _proto: *HtmlElement, _tag_name: String, pub fn asElement(self: *Unknown) *Element { return self._proto._proto; } pub fn asNode(self: *Unknown) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Unknown); pub const Meta = struct { pub const name = "HTMLUnknownElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/html/Video.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Media = @import("Media.zig"); const Video = @This(); _proto: *Media, pub fn asMedia(self: *Video) *Media { return self._proto; } pub fn asElement(self: *Video) *Element { return self._proto.asElement(); } pub fn asConstElement(self: *const Video) *const Element { return self._proto.asConstElement(); } pub fn asNode(self: *Video) *Node { return self.asElement().asNode(); } pub fn getVideoWidth(_: *const Video) u32 { return 0; } pub fn getVideoHeight(_: *const Video) u32 { return 0; } pub fn getPoster(self: *const Video, page: *Page) ![]const u8 { const element = self.asConstElement(); const poster = element.getAttributeSafe(comptime .wrap("poster")) orelse return ""; if (poster.len == 0) { return ""; } const URL = @import("../../URL.zig"); return URL.resolve(page.call_arena, page.base(), poster, .{ .encode = true }); } pub fn setPoster(self: *Video, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("poster"), .wrap(value), page); } pub const JsApi = struct { pub const bridge = js.Bridge(Video); pub const Meta = struct { pub const name = "HTMLVideoElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const poster = bridge.accessor(Video.getPoster, Video.setPoster, .{}); pub const videoWidth = bridge.accessor(Video.getVideoWidth, null, .{}); pub const videoHeight = bridge.accessor(Video.getVideoHeight, null, .{}); }; ================================================ FILE: src/browser/webapi/element/svg/Generic.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Svg = @import("../Svg.zig"); const Generic = @This(); _proto: *Svg, _tag: Element.Tag, pub fn asElement(self: *Generic) *Element { return self._proto._proto; } pub fn asNode(self: *Generic) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Generic); pub const Meta = struct { pub const name = "SVGGenericElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/element/svg/Rect.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Svg = @import("../Svg.zig"); const Rect = @This(); _proto: *Svg, pub fn asElement(self: *Rect) *Element { return self._proto._proto; } pub fn asNode(self: *Rect) *Node { return self.asElement().asNode(); } pub const JsApi = struct { pub const bridge = js.Bridge(Rect); pub const Meta = struct { pub const name = "SVGRectElement"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; }; ================================================ FILE: src/browser/webapi/encoding/TextDecoder.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; const TextDecoder = @This(); _fatal: bool, _arena: Allocator, _ignore_bom: bool, _stream: std.ArrayList(u8), const Label = enum { utf8, @"utf-8", @"unicode-1-1-utf-8", }; const InitOpts = struct { fatal: bool = false, ignoreBOM: bool = false, }; pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { if (label_) |label| { _ = std.meta.stringToEnum(Label, label) orelse return error.RangeError; } const arena = try page.getArena(.{ .debug = "TextDecoder" }); errdefer page.releaseArena(arena); const opts = opts_ orelse InitOpts{}; const self = try arena.create(TextDecoder); self.* = .{ ._arena = arena, ._stream = .empty, ._fatal = opts.fatal, ._ignore_bom = opts.ignoreBOM, }; return self; } pub fn deinit(self: *TextDecoder, _: bool, session: *Session) void { session.releaseArena(self._arena); } pub fn getIgnoreBOM(self: *const TextDecoder) bool { return self._ignore_bom; } pub fn getFatal(self: *const TextDecoder) bool { return self._fatal; } const DecodeOpts = struct { stream: bool = false, }; pub fn decode(self: *TextDecoder, input_: ?[]const u8, opts_: ?DecodeOpts) ![]const u8 { var input = input_ orelse return ""; const opts: DecodeOpts = opts_ orelse .{}; if (self._stream.items.len > 0) { try self._stream.appendSlice(self._arena, input); input = self._stream.items; } if (self._fatal and !std.unicode.utf8ValidateSlice(input)) { if (opts.stream) { if (self._stream.items.len == 0) { try self._stream.appendSlice(self._arena, input); } return ""; } return error.InvalidUtf8; } self._stream.clearRetainingCapacity(); if (self._ignore_bom == false and std.mem.startsWith(u8, input, &.{ 0xEF, 0xBB, 0xBF })) { return input[3..]; } return input; } pub const JsApi = struct { pub const bridge = js.Bridge(TextDecoder); pub const Meta = struct { pub const name = "TextDecoder"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(TextDecoder.deinit); }; pub const constructor = bridge.constructor(TextDecoder.init, .{}); pub const decode = bridge.function(TextDecoder.decode, .{}); pub const encoding = bridge.property("utf-8", .{ .template = false }); pub const fatal = bridge.accessor(TextDecoder.getFatal, null, .{}); pub const ignoreBOM = bridge.accessor(TextDecoder.getIgnoreBOM, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: TextDecoder" { try testing.htmlRunner("encoding/text_decoder.html", .{}); } ================================================ FILE: src/browser/webapi/encoding/TextDecoderStream.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const ReadableStream = @import("../streams/ReadableStream.zig"); const WritableStream = @import("../streams/WritableStream.zig"); const TransformStream = @import("../streams/TransformStream.zig"); const TextDecoderStream = @This(); _transform: *TransformStream, _fatal: bool, _ignore_bom: bool, const Label = enum { utf8, @"utf-8", @"unicode-1-1-utf-8", }; const InitOpts = struct { fatal: bool = false, ignoreBOM: bool = false, }; pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !TextDecoderStream { if (label_) |label| { _ = std.meta.stringToEnum(Label, label) orelse return error.RangeError; } const opts = opts_ orelse InitOpts{}; const decodeFn: TransformStream.ZigTransformFn = blk: { if (opts.ignoreBOM) { break :blk struct { fn decode(controller: *TransformStream.DefaultController, chunk: js.Value) !void { return decodeTransform(controller, chunk, true); } }.decode; } else { break :blk struct { fn decode(controller: *TransformStream.DefaultController, chunk: js.Value) !void { return decodeTransform(controller, chunk, false); } }.decode; } }; const transform = try TransformStream.initWithZigTransform(decodeFn, page); return .{ ._transform = transform, ._fatal = opts.fatal, ._ignore_bom = opts.ignoreBOM, }; } fn decodeTransform(controller: *TransformStream.DefaultController, chunk: js.Value, ignoreBOM: bool) !void { // chunk should be a Uint8Array; decode it as UTF-8 string const typed_array = try chunk.toZig(js.TypedArray(u8)); var input = typed_array.values; // Strip UTF-8 BOM if present if (ignoreBOM == false and std.mem.startsWith(u8, input, &.{ 0xEF, 0xBB, 0xBF })) { input = input[3..]; } // Per spec, empty chunks produce no output if (input.len == 0) return; try controller.enqueue(.{ .string = input }); } pub fn getReadable(self: *const TextDecoderStream) *ReadableStream { return self._transform.getReadable(); } pub fn getWritable(self: *const TextDecoderStream) *WritableStream { return self._transform.getWritable(); } pub fn getFatal(self: *const TextDecoderStream) bool { return self._fatal; } pub fn getIgnoreBOM(self: *const TextDecoderStream) bool { return self._ignore_bom; } pub const JsApi = struct { pub const bridge = js.Bridge(TextDecoderStream); pub const Meta = struct { pub const name = "TextDecoderStream"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(TextDecoderStream.init, .{}); pub const encoding = bridge.property("utf-8", .{ .template = false }); pub const readable = bridge.accessor(TextDecoderStream.getReadable, null, .{}); pub const writable = bridge.accessor(TextDecoderStream.getWritable, null, .{}); pub const fatal = bridge.accessor(TextDecoderStream.getFatal, null, .{}); pub const ignoreBOM = bridge.accessor(TextDecoderStream.getIgnoreBOM, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: TextDecoderStream" { try testing.htmlRunner("streams/text_decoder_stream.html", .{}); } ================================================ FILE: src/browser/webapi/encoding/TextEncoder.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const TextEncoder = @This(); _pad: bool = false, pub fn init() TextEncoder { return .{}; } pub fn encode(_: *const TextEncoder, v: []const u8) !js.TypedArray(u8) { if (!std.unicode.utf8ValidateSlice(v)) { return error.InvalidUtf8; } return .{ .values = v }; } pub const JsApi = struct { pub const bridge = js.Bridge(TextEncoder); pub const Meta = struct { pub const name = "TextEncoder"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; }; pub const constructor = bridge.constructor(TextEncoder.init, .{}); pub const encode = bridge.function(TextEncoder.encode, .{ .as_typed_array = true }); pub const encoding = bridge.property("utf-8", .{ .template = false }); }; const testing = @import("../../../testing.zig"); test "WebApi: TextEncoder" { try testing.htmlRunner("encoding/text_encoder.html", .{}); } ================================================ FILE: src/browser/webapi/encoding/TextEncoderStream.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const ReadableStream = @import("../streams/ReadableStream.zig"); const WritableStream = @import("../streams/WritableStream.zig"); const TransformStream = @import("../streams/TransformStream.zig"); const TextEncoderStream = @This(); _transform: *TransformStream, pub fn init(page: *Page) !TextEncoderStream { const transform = try TransformStream.initWithZigTransform(&encodeTransform, page); return .{ ._transform = transform, }; } fn encodeTransform(controller: *TransformStream.DefaultController, chunk: js.Value) !void { // chunk should be a JS string; encode it as UTF-8 bytes (Uint8Array) const str = chunk.isString() orelse return error.InvalidChunk; const slice = try str.toSlice(); try controller.enqueue(.{ .uint8array = .{ .values = slice } }); } pub fn getReadable(self: *const TextEncoderStream) *ReadableStream { return self._transform.getReadable(); } pub fn getWritable(self: *const TextEncoderStream) *WritableStream { return self._transform.getWritable(); } pub const JsApi = struct { pub const bridge = js.Bridge(TextEncoderStream); pub const Meta = struct { pub const name = "TextEncoderStream"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(TextEncoderStream.init, .{}); pub const encoding = bridge.property("utf-8", .{ .template = false }); pub const readable = bridge.accessor(TextEncoderStream.getReadable, null, .{}); pub const writable = bridge.accessor(TextEncoderStream.getWritable, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: TextEncoderStream" { try testing.htmlRunner("streams/transform_stream.html", .{}); } ================================================ FILE: src/browser/webapi/event/CompositionEvent.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; const CompositionEvent = @This(); _proto: *Event, _data: []const u8 = "", const CompositionEventOptions = struct { data: ?[]const u8 = null, }; const Options = Event.inheritOptions(CompositionEvent, CompositionEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CompositionEvent { const arena = try page.getArena(.{ .debug = "CompositionEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; const event = try page._factory.event( arena, type_string, CompositionEvent{ ._proto = undefined, ._data = if (opts.data) |str| try arena.dupe(u8, str) else "", }, ); Event.populatePrototypes(event, opts, false); return event; } pub fn deinit(self: *CompositionEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *CompositionEvent) *Event { return self._proto; } pub fn getData(self: *const CompositionEvent) []const u8 { return self._data; } pub const JsApi = struct { pub const bridge = js.Bridge(CompositionEvent); pub const Meta = struct { pub const name = "CompositionEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(CompositionEvent.deinit); }; pub const constructor = bridge.constructor(CompositionEvent.init, .{}); pub const data = bridge.accessor(CompositionEvent.getData, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: CompositionEvent" { try testing.htmlRunner("event/composition.html", .{}); } ================================================ FILE: src/browser/webapi/event/CustomEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; const CustomEvent = @This(); _proto: *Event, _detail: ?js.Value.Temp = null, _arena: Allocator, const CustomEventOptions = struct { detail: ?js.Value.Temp = null, }; const Options = Event.inheritOptions(CustomEvent, CustomEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CustomEvent { const arena = try page.getArena(.{ .debug = "CustomEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; const event = try page._factory.event( arena, type_string, CustomEvent{ ._arena = arena, ._proto = undefined, ._detail = opts.detail, }, ); Event.populatePrototypes(event, opts, false); return event; } pub fn initCustomEvent( self: *CustomEvent, event_string: []const u8, bubbles: ?bool, cancelable: ?bool, detail_: ?js.Value.Temp, ) !void { // This function can only be called after the constructor has called. // So we assume proto is initialized already by constructor. self._proto._type_string = try String.init(self._proto._arena, event_string, .{}); self._proto._bubbles = bubbles orelse false; self._proto._cancelable = cancelable orelse false; // Detail is stored separately. self._detail = detail_; } pub fn deinit(self: *CustomEvent, shutdown: bool, session: *Session) void { if (self._detail) |d| { d.release(); } self._proto.deinit(shutdown, session); } pub fn asEvent(self: *CustomEvent) *Event { return self._proto; } pub fn getDetail(self: *const CustomEvent) ?js.Value.Temp { return self._detail; } pub const JsApi = struct { pub const bridge = js.Bridge(CustomEvent); pub const Meta = struct { pub const name = "CustomEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(CustomEvent.deinit); pub const enumerable = false; }; pub const constructor = bridge.constructor(CustomEvent.init, .{}); pub const detail = bridge.accessor(CustomEvent.getDetail, null, .{}); pub const initCustomEvent = bridge.function(CustomEvent.initCustomEvent, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: CustomEvent" { try testing.htmlRunner("event/custom_event.html", .{}); } ================================================ FILE: src/browser/webapi/event/ErrorEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; const ErrorEvent = @This(); _proto: *Event, _message: []const u8 = "", _filename: []const u8 = "", _line_number: u32 = 0, _column_number: u32 = 0, _error: ?js.Value.Temp = null, _arena: Allocator, pub const ErrorEventOptions = struct { message: ?[]const u8 = null, filename: ?[]const u8 = null, lineno: u32 = 0, colno: u32 = 0, @"error": ?js.Value.Temp = null, }; const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ErrorEvent { const arena = try page.getArena(.{ .debug = "ErrorEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, opts_, false, page); } pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*ErrorEvent { const arena = try page.getArena(.{ .debug = "ErrorEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, opts_, true, page); } fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*ErrorEvent { const opts = opts_ orelse Options{}; const event = try page._factory.event( arena, typ, ErrorEvent{ ._arena = arena, ._proto = undefined, ._message = if (opts.message) |str| try arena.dupe(u8, str) else "", ._filename = if (opts.filename) |str| try arena.dupe(u8, str) else "", ._line_number = opts.lineno, ._column_number = opts.colno, ._error = opts.@"error", }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *ErrorEvent, shutdown: bool, session: *Session) void { if (self._error) |e| { e.release(); } self._proto.deinit(shutdown, session); } pub fn asEvent(self: *ErrorEvent) *Event { return self._proto; } pub fn getMessage(self: *const ErrorEvent) []const u8 { return self._message; } pub fn getFilename(self: *const ErrorEvent) []const u8 { return self._filename; } pub fn getLineNumber(self: *const ErrorEvent) u32 { return self._line_number; } pub fn getColumnNumber(self: *const ErrorEvent) u32 { return self._column_number; } pub fn getError(self: *const ErrorEvent) ?js.Value.Temp { return self._error; } pub const JsApi = struct { pub const bridge = js.Bridge(ErrorEvent); pub const Meta = struct { pub const name = "ErrorEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(ErrorEvent.deinit); }; // Start API pub const constructor = bridge.constructor(ErrorEvent.init, .{}); pub const message = bridge.accessor(ErrorEvent.getMessage, null, .{}); pub const filename = bridge.accessor(ErrorEvent.getFilename, null, .{}); pub const lineno = bridge.accessor(ErrorEvent.getLineNumber, null, .{}); pub const colno = bridge.accessor(ErrorEvent.getColumnNumber, null, .{}); pub const @"error" = bridge.accessor(ErrorEvent.getError, null, .{ .null_as_undefined = true }); }; const testing = @import("../../../testing.zig"); test "WebApi: ErrorEvent" { try testing.htmlRunner("event/error.html", .{}); } ================================================ FILE: src/browser/webapi/event/FocusEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Allocator = std.mem.Allocator; const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); const EventTarget = @import("../EventTarget.zig"); const UIEvent = @import("UIEvent.zig"); const FocusEvent = @This(); _proto: *UIEvent, _related_target: ?*EventTarget = null, pub const FocusEventOptions = struct { relatedTarget: ?*EventTarget = null, }; pub const Options = Event.inheritOptions( FocusEvent, FocusEventOptions, ); pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*FocusEvent { const arena = try page.getArena(.{ .debug = "FocusEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, _opts, true, page); } pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*FocusEvent { const arena = try page.getArena(.{ .debug = "FocusEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, _opts, false, page); } fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*FocusEvent { const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, typ, FocusEvent{ ._proto = undefined, ._related_target = opts.relatedTarget, }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *FocusEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *FocusEvent) *Event { return self._proto.asEvent(); } pub fn getRelatedTarget(self: *const FocusEvent) ?*EventTarget { return self._related_target; } pub const JsApi = struct { pub const bridge = js.Bridge(FocusEvent); pub const Meta = struct { pub const name = "FocusEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(FocusEvent.deinit); }; pub const constructor = bridge.constructor(FocusEvent.init, .{}); pub const relatedTarget = bridge.accessor(FocusEvent.getRelatedTarget, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: FocusEvent" { try testing.htmlRunner("event/focus.html", .{}); } ================================================ FILE: src/browser/webapi/event/InputEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); const UIEvent = @import("UIEvent.zig"); const Allocator = std.mem.Allocator; const InputEvent = @This(); _proto: *UIEvent, _data: ?[]const u8, // TODO: add dataTransfer _input_type: []const u8, _is_composing: bool, pub const InputEventOptions = struct { data: ?[]const u8 = null, inputType: ?[]const u8 = null, isComposing: bool = false, }; const Options = Event.inheritOptions( InputEvent, InputEventOptions, ); pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*InputEvent { const arena = try page.getArena(.{ .debug = "InputEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, _opts, true, page); } pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*InputEvent { const arena = try page.getArena(.{ .debug = "InputEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, _opts, false, page); } fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*InputEvent { const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, typ, InputEvent{ ._proto = undefined, ._data = if (opts.data) |d| try arena.dupe(u8, d) else null, ._input_type = if (opts.inputType) |it| try arena.dupe(u8, it) else "", ._is_composing = opts.isComposing, }, ); Event.populatePrototypes(event, opts, trusted); // https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event const rootevt = event._proto._proto; rootevt._bubbles = true; rootevt._cancelable = false; rootevt._composed = true; return event; } pub fn deinit(self: *InputEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *InputEvent) *Event { return self._proto.asEvent(); } pub fn getData(self: *const InputEvent) ?[]const u8 { return self._data; } pub fn getInputType(self: *const InputEvent) []const u8 { return self._input_type; } pub fn getIsComposing(self: *const InputEvent) bool { return self._is_composing; } pub const JsApi = struct { pub const bridge = js.Bridge(InputEvent); pub const Meta = struct { pub const name = "InputEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(InputEvent.deinit); }; pub const constructor = bridge.constructor(InputEvent.init, .{}); pub const data = bridge.accessor(InputEvent.getData, null, .{}); pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{}); pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{}); }; ================================================ FILE: src/browser/webapi/event/KeyboardEvent.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const UIEvent = @import("UIEvent.zig"); const Allocator = std.mem.Allocator; const KeyboardEvent = @This(); _proto: *UIEvent, _key: Key, _code: []const u8, _ctrl_key: bool, _shift_key: bool, _alt_key: bool, _meta_key: bool, _location: Location, _repeat: bool, _is_composing: bool, // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values pub const Key = union(enum) { // Special Key Values Dead, Undefined, Unidentified, // Modifier Keys Alt, AltGraph, CapsLock, Control, Fn, FnLock, Hyper, Meta, NumLock, ScrollLock, Shift, Super, Symbol, SymbolLock, // Whitespace Keys Enter, Tab, // Navigation Keys ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Home, PageDown, PageUp, // Editing Keys Backspace, Clear, Copy, CrSel, Cut, Delete, EraseEof, ExSel, Insert, Paste, Redo, Undo, // UI Keys Accept, Again, Attn, Cancel, ContextMenu, Escape, Execute, Find, Finish, Help, Pause, Play, Props, Select, ZoomIn, ZoomOut, // Function Keys F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, // Printable keys (single character, space, etc.) standard: []const u8, pub fn fromString(allocator: std.mem.Allocator, str: []const u8) !Key { const key_type_info = @typeInfo(Key); inline for (key_type_info.@"union".fields) |field| { if (comptime std.mem.eql(u8, field.name, "standard")) continue; if (std.mem.eql(u8, field.name, str)) { return @unionInit(Key, field.name, {}); } } const duped = try allocator.dupe(u8, str); return .{ .standard = duped }; } /// Returns true if this key represents a printable character that should be /// inserted into text input elements. This includes alphanumeric characters, /// punctuation, symbols, and space. pub fn isPrintable(self: Key) bool { return switch (self) { .standard => |s| s.len > 0, else => false, }; } /// Returns the string representation that should be inserted into text input. /// For most keys this is just the key itself, but some keys like Enter need /// special handling (e.g., newline for textarea, form submission for input). pub fn asString(self: Key) []const u8 { return switch (self) { .standard => |s| s, else => |k| @tagName(k), }; } }; pub const Location = enum(i32) { DOM_KEY_LOCATION_STANDARD = 0, DOM_KEY_LOCATION_LEFT = 1, DOM_KEY_LOCATION_RIGHT = 2, DOM_KEY_LOCATION_NUMPAD = 3, }; pub const KeyboardEventOptions = struct { key: []const u8 = "", code: ?[]const u8 = null, location: i32 = 0, repeat: bool = false, isComposing: bool = false, ctrlKey: bool = false, shiftKey: bool = false, altKey: bool = false, metaKey: bool = false, }; const Options = Event.inheritOptions( KeyboardEvent, KeyboardEventOptions, ); pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*KeyboardEvent { const arena = try page.getArena(.{ .debug = "KeyboardEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, _opts, true, page); } pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*KeyboardEvent { const arena = try page.getArena(.{ .debug = "KeyboardEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, _opts, false, page); } fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*KeyboardEvent { const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, typ, KeyboardEvent{ ._proto = undefined, ._key = try Key.fromString(arena, opts.key), ._location = std.meta.intToEnum(Location, opts.location) catch return error.TypeError, ._code = if (opts.code) |c| try arena.dupe(u8, c) else "", ._repeat = opts.repeat, ._is_composing = opts.isComposing, ._ctrl_key = opts.ctrlKey, ._shift_key = opts.shiftKey, ._alt_key = opts.altKey, ._meta_key = opts.metaKey, }, ); Event.populatePrototypes(event, opts, trusted); // https://w3c.github.io/uievents/#event-type-keyup const rootevt = event._proto._proto; rootevt._bubbles = true; rootevt._cancelable = true; rootevt._composed = true; return event; } pub fn deinit(self: *KeyboardEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *KeyboardEvent) *Event { return self._proto.asEvent(); } pub fn getAltKey(self: *const KeyboardEvent) bool { return self._alt_key; } pub fn getCtrlKey(self: *const KeyboardEvent) bool { return self._ctrl_key; } pub fn getIsComposing(self: *const KeyboardEvent) bool { return self._is_composing; } pub fn getKey(self: *const KeyboardEvent) Key { return self._key; } pub fn getCode(self: *const KeyboardEvent) []const u8 { return self._code; } pub fn getLocation(self: *const KeyboardEvent) i32 { return @intFromEnum(self._location); } pub fn getMetaKey(self: *const KeyboardEvent) bool { return self._meta_key; } pub fn getRepeat(self: *const KeyboardEvent) bool { return self._repeat; } pub fn getShiftKey(self: *const KeyboardEvent) bool { return self._shift_key; } pub fn getModifierState(self: *const KeyboardEvent, str: []const u8) !bool { const key = try Key.fromString(self._proto._proto._arena, str); switch (key) { .Alt, .AltGraph => return self._alt_key, .Shift => return self._shift_key, .Control => return self._ctrl_key, .Meta => return self._meta_key, .standard => |s| if (std.mem.eql(u8, s, "Accel")) { return self._ctrl_key or self._meta_key; }, else => {}, } return false; } pub const JsApi = struct { pub const bridge = js.Bridge(KeyboardEvent); pub const Meta = struct { pub const name = "KeyboardEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(KeyboardEvent.deinit); }; pub const constructor = bridge.constructor(KeyboardEvent.init, .{}); pub const altKey = bridge.accessor(KeyboardEvent.getAltKey, null, .{}); pub const ctrlKey = bridge.accessor(KeyboardEvent.getCtrlKey, null, .{}); pub const isComposing = bridge.accessor(KeyboardEvent.getIsComposing, null, .{}); pub const key = bridge.accessor(struct { fn keyAsString(self: *const KeyboardEvent) []const u8 { return self._key.asString(); } }.keyAsString, null, .{}); pub const code = bridge.accessor(KeyboardEvent.getCode, null, .{}); pub const location = bridge.accessor(KeyboardEvent.getLocation, null, .{}); pub const metaKey = bridge.accessor(KeyboardEvent.getMetaKey, null, .{}); pub const repeat = bridge.accessor(KeyboardEvent.getRepeat, null, .{}); pub const shiftKey = bridge.accessor(KeyboardEvent.getShiftKey, null, .{}); pub const getModifierState = bridge.function(KeyboardEvent.getModifierState, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: KeyboardEvent" { try testing.htmlRunner("event/keyboard.html", .{}); } ================================================ FILE: src/browser/webapi/event/MessageEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Window = @import("../Window.zig"); const Allocator = std.mem.Allocator; const MessageEvent = @This(); _proto: *Event, _data: ?js.Value.Temp = null, _origin: []const u8 = "", _source: ?*Window = null, const MessageEventOptions = struct { data: ?js.Value.Temp = null, origin: ?[]const u8 = null, source: ?*Window = null, }; const Options = Event.inheritOptions(MessageEvent, MessageEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent { const arena = try page.getArena(.{ .debug = "MessageEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, opts_, false, page); } pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*MessageEvent { const arena = try page.getArena(.{ .debug = "MessageEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, opts_, true, page); } fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*MessageEvent { const opts = opts_ orelse Options{}; const event = try page._factory.event( arena, typ, MessageEvent{ ._proto = undefined, ._data = opts.data, ._origin = if (opts.origin) |str| try arena.dupe(u8, str) else "", ._source = opts.source, }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *MessageEvent, shutdown: bool, session: *Session) void { if (self._data) |d| { d.release(); } self._proto.deinit(shutdown, session); } pub fn asEvent(self: *MessageEvent) *Event { return self._proto; } pub fn getData(self: *const MessageEvent) ?js.Value.Temp { return self._data; } pub fn getOrigin(self: *const MessageEvent) []const u8 { return self._origin; } pub fn getSource(self: *const MessageEvent) ?*Window { return self._source; } pub const JsApi = struct { pub const bridge = js.Bridge(MessageEvent); pub const Meta = struct { pub const name = "MessageEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(MessageEvent.deinit); }; pub const constructor = bridge.constructor(MessageEvent.init, .{}); pub const data = bridge.accessor(MessageEvent.getData, null, .{}); pub const origin = bridge.accessor(MessageEvent.getOrigin, null, .{}); pub const source = bridge.accessor(MessageEvent.getSource, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: MessageEvent" { try testing.htmlRunner("event/message.html", .{}); } ================================================ FILE: src/browser/webapi/event/MouseEvent.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); const EventTarget = @import("../EventTarget.zig"); const UIEvent = @import("UIEvent.zig"); const PointerEvent = @import("PointerEvent.zig"); const Allocator = std.mem.Allocator; const MouseEvent = @This(); pub const MouseButton = enum(u8) { main = 0, auxillary = 1, secondary = 2, fourth = 3, fifth = 4, }; pub const Type = union(enum) { generic, pointer_event: *PointerEvent, wheel_event: *@import("WheelEvent.zig"), }; _type: Type, _proto: *UIEvent, _alt_key: bool, _button: MouseButton, _buttons: u16, _client_x: f64, _client_y: f64, _ctrl_key: bool, _meta_key: bool, // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/relatedTarget _related_target: ?*EventTarget = null, _screen_x: f64, _screen_y: f64, _shift_key: bool, pub const MouseEventOptions = struct { screenX: f64 = 0.0, screenY: f64 = 0.0, clientX: f64 = 0.0, clientY: f64 = 0.0, ctrlKey: bool = false, shiftKey: bool = false, altKey: bool = false, metaKey: bool = false, button: i32 = 0, buttons: u16 = 0, relatedTarget: ?*EventTarget = null, }; pub const Options = Event.inheritOptions( MouseEvent, MouseEventOptions, ); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { const arena = try page.getArena(.{ .debug = "MouseEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, _opts, false, page); } pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*MouseEvent { const arena = try page.getArena(.{ .debug = "MouseEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, _opts, true, page); } fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*MouseEvent { const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, typ, MouseEvent{ ._type = .generic, ._proto = undefined, ._screen_x = opts.screenX, ._screen_y = opts.screenY, ._client_x = opts.clientX, ._client_y = opts.clientY, ._ctrl_key = opts.ctrlKey, ._shift_key = opts.shiftKey, ._alt_key = opts.altKey, ._meta_key = opts.metaKey, ._button = std.meta.intToEnum(MouseButton, opts.button) catch return error.TypeError, ._buttons = opts.buttons, ._related_target = opts.relatedTarget, }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *MouseEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *MouseEvent) *Event { return self._proto.asEvent(); } pub fn as(self: *MouseEvent, comptime T: type) *T { return self.is(T).?; } pub fn is(self: *MouseEvent, comptime T: type) ?*T { switch (self._type) { .generic => return if (T == MouseEvent) self else null, .pointer_event => |e| return if (T == PointerEvent) e else null, .wheel_event => |e| return if (T == @import("WheelEvent.zig")) e else null, } return null; } pub fn getAltKey(self: *const MouseEvent) bool { return self._alt_key; } pub fn getButton(self: *const MouseEvent) u8 { return @intFromEnum(self._button); } pub fn getButtons(self: *const MouseEvent) u16 { return self._buttons; } pub fn getClientX(self: *const MouseEvent) f64 { return self._client_x; } pub fn getClientY(self: *const MouseEvent) f64 { return self._client_y; } pub fn getCtrlKey(self: *const MouseEvent) bool { return self._ctrl_key; } pub fn getMetaKey(self: *const MouseEvent) bool { return self._meta_key; } pub fn getPageX(self: *const MouseEvent) f64 { // this should be clientX + window.scrollX return self._client_x; } pub fn getPageY(self: *const MouseEvent) f64 { // this should be clientY + window.scrollY return self._client_y; } pub fn getRelatedTarget(self: *const MouseEvent) ?*EventTarget { return self._related_target; } pub fn getScreenX(self: *const MouseEvent) f64 { return self._screen_x; } pub fn getScreenY(self: *const MouseEvent) f64 { return self._screen_y; } pub fn getShiftKey(self: *const MouseEvent) bool { return self._shift_key; } pub const JsApi = struct { pub const bridge = js.Bridge(MouseEvent); pub const Meta = struct { pub const name = "MouseEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(MouseEvent.deinit); }; pub const constructor = bridge.constructor(MouseEvent.init, .{}); pub const altKey = bridge.accessor(getAltKey, null, .{}); pub const button = bridge.accessor(getButton, null, .{}); pub const buttons = bridge.accessor(getButtons, null, .{}); pub const clientX = bridge.accessor(getClientX, null, .{}); pub const clientY = bridge.accessor(getClientY, null, .{}); pub const ctrlKey = bridge.accessor(getCtrlKey, null, .{}); pub const metaKey = bridge.accessor(getMetaKey, null, .{}); pub const offsetX = bridge.property(0.0, .{ .template = false }); pub const offsetY = bridge.property(0.0, .{ .template = false }); pub const pageX = bridge.accessor(getPageX, null, .{}); pub const pageY = bridge.accessor(getPageY, null, .{}); pub const relatedTarget = bridge.accessor(getRelatedTarget, null, .{}); pub const screenX = bridge.accessor(getScreenX, null, .{}); pub const screenY = bridge.accessor(getScreenY, null, .{}); pub const shiftKey = bridge.accessor(getShiftKey, null, .{}); pub const x = bridge.accessor(getClientX, null, .{}); pub const y = bridge.accessor(getClientY, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: MouseEvent" { try testing.htmlRunner("event/mouse.html", .{}); } ================================================ FILE: src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const NavigationHistoryEntry = @import("../navigation/NavigationHistoryEntry.zig"); const NavigationType = @import("../navigation/root.zig").NavigationType; const Allocator = std.mem.Allocator; const NavigationCurrentEntryChangeEvent = @This(); _proto: *Event, _from: *NavigationHistoryEntry, _navigation_type: ?NavigationType, const NavigationCurrentEntryChangeEventOptions = struct { from: *NavigationHistoryEntry, navigationType: ?[]const u8 = null, }; const Options = Event.inheritOptions( NavigationCurrentEntryChangeEvent, NavigationCurrentEntryChangeEventOptions, ); pub fn init(typ: []const u8, opts: Options, page: *Page) !*NavigationCurrentEntryChangeEvent { const arena = try page.getArena(.{ .debug = "NavigationCurrentEntryChangeEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, opts, false, page); } pub fn initTrusted(typ: String, opts: Options, page: *Page) !*NavigationCurrentEntryChangeEvent { const arena = try page.getArena(.{ .debug = "NavigationCurrentEntryChangeEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, opts, true, page); } fn initWithTrusted( arena: Allocator, typ: String, opts: Options, trusted: bool, page: *Page, ) !*NavigationCurrentEntryChangeEvent { const navigation_type = if (opts.navigationType) |nav_type_str| std.meta.stringToEnum(NavigationType, nav_type_str) else null; const event = try page._factory.event( arena, typ, NavigationCurrentEntryChangeEvent{ ._proto = undefined, ._from = opts.from, ._navigation_type = navigation_type, }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *NavigationCurrentEntryChangeEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *NavigationCurrentEntryChangeEvent) *Event { return self._proto; } pub fn getFrom(self: *NavigationCurrentEntryChangeEvent) *NavigationHistoryEntry { return self._from; } pub fn getNavigationType(self: *const NavigationCurrentEntryChangeEvent) ?[]const u8 { return if (self._navigation_type) |nav_type| @tagName(nav_type) else null; } pub const JsApi = struct { pub const bridge = js.Bridge(NavigationCurrentEntryChangeEvent); pub const Meta = struct { pub const name = "NavigationCurrentEntryChangeEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(NavigationCurrentEntryChangeEvent.deinit); }; pub const constructor = bridge.constructor(NavigationCurrentEntryChangeEvent.init, .{}); pub const from = bridge.accessor(NavigationCurrentEntryChangeEvent.getFrom, null, .{}); pub const navigationType = bridge.accessor(NavigationCurrentEntryChangeEvent.getNavigationType, null, .{}); }; ================================================ FILE: src/browser/webapi/event/PageTransitionEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; // https://developer.mozilla.org/en-US/docs/Web/API/PageTransitionEvent const PageTransitionEvent = @This(); _proto: *Event, _persisted: bool, const PageTransitionEventOptions = struct { persisted: ?bool = false, }; const Options = Event.inheritOptions(PageTransitionEvent, PageTransitionEventOptions); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PageTransitionEvent { const arena = try page.getArena(.{ .debug = "PageTransitionEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, _opts, false, page); } pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*PageTransitionEvent { const arena = try page.getArena(.{ .debug = "PageTransitionEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, _opts, true, page); } fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*PageTransitionEvent { const opts = _opts orelse Options{}; const event = try page._factory.event( arena, typ, PageTransitionEvent{ ._proto = undefined, ._persisted = opts.persisted orelse false, }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *PageTransitionEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PageTransitionEvent) *Event { return self._proto; } pub fn getPersisted(self: *PageTransitionEvent) bool { return self._persisted; } pub const JsApi = struct { pub const bridge = js.Bridge(PageTransitionEvent); pub const Meta = struct { pub const name = "PageTransitionEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(PageTransitionEvent.deinit); }; pub const constructor = bridge.constructor(PageTransitionEvent.init, .{}); pub const persisted = bridge.accessor(PageTransitionEvent.getPersisted, null, .{}); }; ================================================ FILE: src/browser/webapi/event/PointerEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const MouseEvent = @import("MouseEvent.zig"); const PointerEvent = @This(); const PointerType = enum { empty, mouse, pen, touch, fn fromString(s: []const u8) PointerType { if (std.mem.eql(u8, s, "")) return .empty; if (std.mem.eql(u8, s, "mouse")) return .mouse; if (std.mem.eql(u8, s, "pen")) return .pen; if (std.mem.eql(u8, s, "touch")) return .touch; return .empty; } fn toString(self: PointerType) []const u8 { return switch (self) { .empty => "", inline else => |pt| @tagName(pt), }; } }; _proto: *MouseEvent, _pointer_id: i32, _pointer_type: PointerType, _width: f64, _height: f64, _pressure: f64, _tangential_pressure: f64, _tilt_x: i32, _tilt_y: i32, _twist: i32, _altitude_angle: f64, _azimuth_angle: f64, _is_primary: bool, pub const PointerEventOptions = struct { pointerId: i32 = 0, pointerType: []const u8 = "", width: f64 = 1.0, height: f64 = 1.0, pressure: f64 = 0.0, tangentialPressure: f64 = 0.0, tiltX: i32 = 0, tiltY: i32 = 0, twist: i32 = 0, altitudeAngle: f64 = std.math.pi / 2.0, azimuthAngle: f64 = 0.0, isPrimary: bool = false, }; const Options = Event.inheritOptions( PointerEvent, PointerEventOptions, ); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PointerEvent { const arena = try page.getArena(.{ .debug = "UIEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = _opts orelse Options{}; const event = try page._factory.mouseEvent( arena, type_string, MouseEvent{ ._type = .{ .pointer_event = undefined }, ._proto = undefined, ._screen_x = opts.screenX, ._screen_y = opts.screenY, ._client_x = opts.clientX, ._client_y = opts.clientY, ._ctrl_key = opts.ctrlKey, ._shift_key = opts.shiftKey, ._alt_key = opts.altKey, ._meta_key = opts.metaKey, ._button = std.meta.intToEnum(MouseEvent.MouseButton, opts.button) catch return error.TypeError, ._buttons = opts.buttons, ._related_target = opts.relatedTarget, }, PointerEvent{ ._proto = undefined, ._pointer_id = opts.pointerId, ._pointer_type = PointerType.fromString(opts.pointerType), ._width = opts.width, ._height = opts.height, ._pressure = opts.pressure, ._tangential_pressure = opts.tangentialPressure, ._tilt_x = opts.tiltX, ._tilt_y = opts.tiltY, ._twist = opts.twist, ._altitude_angle = opts.altitudeAngle, ._azimuth_angle = opts.azimuthAngle, ._is_primary = opts.isPrimary, }, ); Event.populatePrototypes(event, opts, false); return event; } pub fn deinit(self: *PointerEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PointerEvent) *Event { return self._proto.asEvent(); } pub fn getPointerId(self: *const PointerEvent) i32 { return self._pointer_id; } pub fn getPointerType(self: *const PointerEvent) []const u8 { return self._pointer_type.toString(); } pub fn getWidth(self: *const PointerEvent) f64 { return self._width; } pub fn getHeight(self: *const PointerEvent) f64 { return self._height; } pub fn getPressure(self: *const PointerEvent) f64 { return self._pressure; } pub fn getTangentialPressure(self: *const PointerEvent) f64 { return self._tangential_pressure; } pub fn getTiltX(self: *const PointerEvent) i32 { return self._tilt_x; } pub fn getTiltY(self: *const PointerEvent) i32 { return self._tilt_y; } pub fn getTwist(self: *const PointerEvent) i32 { return self._twist; } pub fn getAltitudeAngle(self: *const PointerEvent) f64 { return self._altitude_angle; } pub fn getAzimuthAngle(self: *const PointerEvent) f64 { return self._azimuth_angle; } pub fn getIsPrimary(self: *const PointerEvent) bool { return self._is_primary; } pub const JsApi = struct { pub const bridge = js.Bridge(PointerEvent); pub const Meta = struct { pub const name = "PointerEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(PointerEvent.deinit); }; pub const constructor = bridge.constructor(PointerEvent.init, .{}); pub const pointerId = bridge.accessor(PointerEvent.getPointerId, null, .{}); pub const pointerType = bridge.accessor(PointerEvent.getPointerType, null, .{}); pub const width = bridge.accessor(PointerEvent.getWidth, null, .{}); pub const height = bridge.accessor(PointerEvent.getHeight, null, .{}); pub const pressure = bridge.accessor(PointerEvent.getPressure, null, .{}); pub const tangentialPressure = bridge.accessor(PointerEvent.getTangentialPressure, null, .{}); pub const tiltX = bridge.accessor(PointerEvent.getTiltX, null, .{}); pub const tiltY = bridge.accessor(PointerEvent.getTiltY, null, .{}); pub const twist = bridge.accessor(PointerEvent.getTwist, null, .{}); pub const altitudeAngle = bridge.accessor(PointerEvent.getAltitudeAngle, null, .{}); pub const azimuthAngle = bridge.accessor(PointerEvent.getAzimuthAngle, null, .{}); pub const isPrimary = bridge.accessor(PointerEvent.getIsPrimary, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: PointerEvent" { try testing.htmlRunner("event/pointer.html", .{}); } ================================================ FILE: src/browser/webapi/event/PopStateEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; // https://developer.mozilla.org/en-US/docs/Web/API/PopStateEvent const PopStateEvent = @This(); _proto: *Event, _state: ?[]const u8, const PopStateEventOptions = struct { state: ?[]const u8 = null, }; const Options = Event.inheritOptions(PopStateEvent, PopStateEventOptions); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PopStateEvent { const arena = try page.getArena(.{ .debug = "PopStateEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, _opts, false, page); } pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*PopStateEvent { const arena = try page.getArena(.{ .debug = "PopStateEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, _opts, true, page); } fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*PopStateEvent { const opts = _opts orelse Options{}; const event = try page._factory.event( arena, typ, PopStateEvent{ ._proto = undefined, ._state = opts.state, }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *PopStateEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PopStateEvent) *Event { return self._proto; } pub fn getState(self: *PopStateEvent, page: *Page) !?js.Value { const s = self._state orelse return null; return try page.js.local.?.parseJSON(s); } pub fn hasUAVisualTransition(_: *PopStateEvent) bool { // Not currently supported so we always return false; return false; } pub const JsApi = struct { pub const bridge = js.Bridge(PopStateEvent); pub const Meta = struct { pub const name = "PopStateEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(PopStateEvent.deinit); }; pub const constructor = bridge.constructor(PopStateEvent.init, .{}); pub const state = bridge.accessor(PopStateEvent.getState, null, .{}); pub const hasUAVisualTransition = bridge.accessor(PopStateEvent.hasUAVisualTransition, null, .{}); }; ================================================ FILE: src/browser/webapi/event/ProgressEvent.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; const ProgressEvent = @This(); _proto: *Event, _total: usize = 0, _loaded: usize = 0, _length_computable: bool = false, const ProgressEventOptions = struct { total: usize = 0, loaded: usize = 0, lengthComputable: bool = false, }; const Options = Event.inheritOptions(ProgressEvent, ProgressEventOptions); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*ProgressEvent { const arena = try page.getArena(.{ .debug = "ProgressEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); return initWithTrusted(arena, type_string, _opts, false, page); } pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*ProgressEvent { const arena = try page.getArena(.{ .debug = "ProgressEvent.trusted" }); errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, _opts, true, page); } fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*ProgressEvent { const opts = _opts orelse Options{}; const event = try page._factory.event( arena, typ, ProgressEvent{ ._proto = undefined, ._total = opts.total, ._loaded = opts.loaded, }, ); Event.populatePrototypes(event, opts, trusted); return event; } pub fn deinit(self: *ProgressEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *ProgressEvent) *Event { return self._proto; } pub fn getTotal(self: *const ProgressEvent) usize { return self._total; } pub fn getLoaded(self: *const ProgressEvent) usize { return self._loaded; } pub fn getLengthComputable(self: *const ProgressEvent) bool { return self._length_computable; } pub const JsApi = struct { const js = @import("../../js/js.zig"); pub const bridge = js.Bridge(ProgressEvent); pub const Meta = struct { pub const name = "ProgressEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(ProgressEvent.deinit); }; pub const constructor = bridge.constructor(ProgressEvent.init, .{}); pub const total = bridge.accessor(ProgressEvent.getTotal, null, .{}); pub const loaded = bridge.accessor(ProgressEvent.getLoaded, null, .{}); pub const lengthComputable = bridge.accessor(ProgressEvent.getLengthComputable, null, .{}); }; ================================================ FILE: src/browser/webapi/event/PromiseRejectionEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; const PromiseRejectionEvent = @This(); _proto: *Event, _reason: ?js.Value.Temp = null, _promise: ?js.Promise.Temp = null, const PromiseRejectionEventOptions = struct { reason: ?js.Value.Temp = null, promise: ?js.Promise.Temp = null, }; const Options = Event.inheritOptions(PromiseRejectionEvent, PromiseRejectionEventOptions); pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEvent { const arena = try page.getArena(.{ .debug = "PromiseRejectionEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; const event = try page._factory.event( arena, type_string, PromiseRejectionEvent{ ._proto = undefined, ._reason = opts.reason, ._promise = opts.promise, }, ); Event.populatePrototypes(event, opts, false); return event; } pub fn deinit(self: *PromiseRejectionEvent, shutdown: bool, session: *Session) void { if (self._reason) |r| { r.release(); } if (self._promise) |p| { p.release(); } self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PromiseRejectionEvent) *Event { return self._proto; } pub fn getReason(self: *const PromiseRejectionEvent) ?js.Value.Temp { return self._reason; } pub fn getPromise(self: *const PromiseRejectionEvent) ?js.Promise.Temp { return self._promise; } pub const JsApi = struct { pub const bridge = js.Bridge(PromiseRejectionEvent); pub const Meta = struct { pub const name = "PromiseRejectionEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(PromiseRejectionEvent.deinit); }; pub const constructor = bridge.constructor(PromiseRejectionEvent.init, .{}); pub const reason = bridge.accessor(PromiseRejectionEvent.getReason, null, .{}); pub const promise = bridge.accessor(PromiseRejectionEvent.getPromise, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: PromiseRejectionEvent" { try testing.htmlRunner("event/promise_rejection.html", .{}); } ================================================ FILE: src/browser/webapi/event/TextEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); const UIEvent = @import("UIEvent.zig"); const TextEvent = @This(); _proto: *UIEvent, _data: []const u8 = "", pub const TextEventOptions = struct { data: ?[]const u8 = null, }; pub const Options = Event.inheritOptions( TextEvent, TextEventOptions, ); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*TextEvent { const arena = try page.getArena(.{ .debug = "TextEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, type_string, TextEvent{ ._proto = undefined, ._data = if (opts.data) |str| try arena.dupe(u8, str) else "", }, ); Event.populatePrototypes(event, opts, false); return event; } pub fn deinit(self: *TextEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *TextEvent) *Event { return self._proto.asEvent(); } pub fn getData(self: *const TextEvent) []const u8 { return self._data; } pub fn initTextEvent( self: *TextEvent, typ: []const u8, bubbles: bool, cancelable: bool, view: ?*@import("../Window.zig"), data: []const u8, ) !void { _ = view; // view parameter is ignored in modern implementations const event = self._proto._proto; if (event._event_phase != .none) { // Only allow initialization if event hasn't been dispatched return; } const arena = event._arena; event._type_string = try String.init(arena, typ, .{}); event._bubbles = bubbles; event._cancelable = cancelable; self._data = try arena.dupe(u8, data); } pub const JsApi = struct { pub const bridge = js.Bridge(TextEvent); pub const Meta = struct { pub const name = "TextEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(TextEvent.deinit); }; // No constructor - TextEvent is created via document.createEvent('TextEvent') pub const data = bridge.accessor(TextEvent.getData, null, .{}); pub const initTextEvent = bridge.function(TextEvent.initTextEvent, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: TextEvent" { try testing.htmlRunner("event/text.html", .{}); } ================================================ FILE: src/browser/webapi/event/UIEvent.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); const Window = @import("../Window.zig"); const UIEvent = @This(); _type: Type, _proto: *Event, _detail: u32 = 0, _view: ?*Window = null, pub const Type = union(enum) { generic, mouse_event: *@import("MouseEvent.zig"), keyboard_event: *@import("KeyboardEvent.zig"), focus_event: *@import("FocusEvent.zig"), text_event: *@import("TextEvent.zig"), input_event: *@import("InputEvent.zig"), }; pub const UIEventOptions = struct { detail: u32 = 0, view: ?*Window = null, }; pub const Options = Event.inheritOptions( UIEvent, UIEventOptions, ); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { const arena = try page.getArena(.{ .debug = "UIEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = _opts orelse Options{}; const event = try page._factory.event( arena, type_string, UIEvent{ ._type = .generic, ._proto = undefined, ._detail = opts.detail, ._view = opts.view orelse page.window, }, ); Event.populatePrototypes(event, opts, false); return event; } pub fn deinit(self: *UIEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn as(self: *UIEvent, comptime T: type) *T { return self.is(T).?; } pub fn is(self: *UIEvent, comptime T: type) ?*T { switch (self._type) { .generic => return if (T == UIEvent) self else null, .mouse_event => |e| { if (T == @import("MouseEvent.zig")) return e; return e.is(T); }, .keyboard_event => |e| return if (T == @import("KeyboardEvent.zig")) e else null, .focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null, .text_event => |e| return if (T == @import("TextEvent.zig")) e else null, .input_event => |e| return if (T == @import("InputEvent.zig")) e else null, } return null; } pub fn populateFromOptions(self: *UIEvent, opts: anytype) void { self._detail = opts.detail; self._view = opts.view; } pub fn asEvent(self: *UIEvent) *Event { return self._proto; } pub fn getDetail(self: *UIEvent) u32 { return self._detail; } // sourceCapabilities not implemented pub fn getView(self: *UIEvent, page: *Page) *Window { return self._view orelse page.window; } // deprecated `initUIEvent()` not implemented pub const JsApi = struct { pub const bridge = js.Bridge(UIEvent); pub const Meta = struct { pub const name = "UIEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(UIEvent.deinit); }; pub const constructor = bridge.constructor(UIEvent.init, .{}); pub const detail = bridge.accessor(UIEvent.getDetail, null, .{}); pub const view = bridge.accessor(UIEvent.getView, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: UIEvent" { try testing.htmlRunner("event/ui.html", .{}); } ================================================ FILE: src/browser/webapi/event/WheelEvent.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); const MouseEvent = @import("MouseEvent.zig"); const WheelEvent = @This(); _proto: *MouseEvent, _delta_x: f64, _delta_y: f64, _delta_z: f64, _delta_mode: u32, pub const DOM_DELTA_PIXEL: u32 = 0x00; pub const DOM_DELTA_LINE: u32 = 0x01; pub const DOM_DELTA_PAGE: u32 = 0x02; pub const WheelEventOptions = struct { deltaX: f64 = 0.0, deltaY: f64 = 0.0, deltaZ: f64 = 0.0, deltaMode: u32 = 0, }; pub const Options = Event.inheritOptions( WheelEvent, WheelEventOptions, ); pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*WheelEvent { const arena = try page.getArena(.{ .debug = "WheelEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = _opts orelse Options{}; const event = try page._factory.mouseEvent( arena, type_string, MouseEvent{ ._type = .{ .wheel_event = undefined }, ._proto = undefined, ._screen_x = opts.screenX, ._screen_y = opts.screenY, ._client_x = opts.clientX, ._client_y = opts.clientY, ._ctrl_key = opts.ctrlKey, ._shift_key = opts.shiftKey, ._alt_key = opts.altKey, ._meta_key = opts.metaKey, ._button = std.meta.intToEnum(MouseEvent.MouseButton, opts.button) catch return error.TypeError, ._buttons = opts.buttons, ._related_target = opts.relatedTarget, }, WheelEvent{ ._proto = undefined, ._delta_x = opts.deltaX, ._delta_y = opts.deltaY, ._delta_z = opts.deltaZ, ._delta_mode = opts.deltaMode, }, ); Event.populatePrototypes(event, opts, false); return event; } pub fn deinit(self: *WheelEvent, shutdown: bool, session: *Session) void { self._proto.deinit(shutdown, session); } pub fn asEvent(self: *WheelEvent) *Event { return self._proto.asEvent(); } pub fn getDeltaX(self: *const WheelEvent) f64 { return self._delta_x; } pub fn getDeltaY(self: *const WheelEvent) f64 { return self._delta_y; } pub fn getDeltaZ(self: *const WheelEvent) f64 { return self._delta_z; } pub fn getDeltaMode(self: *const WheelEvent) u32 { return self._delta_mode; } pub const JsApi = struct { pub const bridge = js.Bridge(WheelEvent); pub const Meta = struct { pub const name = "WheelEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(WheelEvent.deinit); }; pub const constructor = bridge.constructor(WheelEvent.init, .{}); pub const deltaX = bridge.accessor(WheelEvent.getDeltaX, null, .{}); pub const deltaY = bridge.accessor(WheelEvent.getDeltaY, null, .{}); pub const deltaZ = bridge.accessor(WheelEvent.getDeltaZ, null, .{}); pub const deltaMode = bridge.accessor(WheelEvent.getDeltaMode, null, .{}); pub const DOM_DELTA_PIXEL = bridge.property(WheelEvent.DOM_DELTA_PIXEL, .{ .template = true }); pub const DOM_DELTA_LINE = bridge.property(WheelEvent.DOM_DELTA_LINE, .{ .template = true }); pub const DOM_DELTA_PAGE = bridge.property(WheelEvent.DOM_DELTA_PAGE, .{ .template = true }); }; const testing = @import("../../../testing.zig"); test "WebApi: WheelEvent" { try testing.htmlRunner("event/wheel.html", .{}); } ================================================ FILE: src/browser/webapi/global_event_handlers.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); const EventTarget = @import("EventTarget.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const Key = struct { target: *EventTarget, handler: Handler, /// Fuses `target` pointer and `handler` enum; used at hashing. /// NEVER use a fusion to retrieve a pointer back. Portability is not guaranteed. /// See `Context.hash`. fn fuse(self: *const Key) u64 { // Check if we have 3 bits available from alignment of 8. if (comptime IS_DEBUG) { lp.assert(@alignOf(EventTarget) == 8, "Key.fuse: incorrect alignment", .{ .event_target_alignment = @alignOf(EventTarget), }); } const ptr = @intFromPtr(self.target) >> 3; if (comptime IS_DEBUG) { lp.assert(ptr < (1 << 57), "Key.fuse: pointer overflow", .{ .ptr = ptr }); } return ptr | (@as(u64, @intFromEnum(self.handler)) << 57); } }; const Context = struct { pub fn hash(_: @This(), key: Key) u64 { return std.hash.int(key.fuse()); } pub fn eql(_: @This(), a: Key, b: Key) bool { return a.fuse() == b.fuse(); } }; pub const Lookup = std.HashMapUnmanaged( Key, js.Function.Global, Context, std.hash_map.default_max_load_percentage, ); /// Enum of known event listeners; increasing the size of it (u7) /// can cause `Key` to behave incorrectly. pub const Handler = enum(u7) { onabort, onanimationcancel, onanimationend, onanimationiteration, onanimationstart, onauxclick, onbeforeinput, onbeforematch, onbeforetoggle, onblur, oncancel, oncanplay, oncanplaythrough, onchange, onclick, onclose, oncommand, oncontentvisibilityautostatechange, oncontextlost, oncontextmenu, oncontextrestored, oncopy, oncuechange, oncut, ondblclick, ondrag, ondragend, ondragenter, ondragexit, ondragleave, ondragover, ondragstart, ondrop, ondurationchange, onemptied, onended, onerror, onfocus, onformdata, onfullscreenchange, onfullscreenerror, ongotpointercapture, oninput, oninvalid, onkeydown, onkeypress, onkeyup, onload, onloadeddata, onloadedmetadata, onloadstart, onlostpointercapture, onmousedown, onmousemove, onmouseout, onmouseover, onmouseup, onpaste, onpause, onplay, onplaying, onpointercancel, onpointerdown, onpointerenter, onpointerleave, onpointermove, onpointerout, onpointerover, onpointerrawupdate, onpointerup, onprogress, onratechange, onreset, onresize, onscroll, onscrollend, onsecuritypolicyviolation, onseeked, onseeking, onselect, onselectionchange, onselectstart, onslotchange, onstalled, onsubmit, onsuspend, ontimeupdate, ontoggle, ontransitioncancel, ontransitionend, ontransitionrun, ontransitionstart, onvolumechange, onwaiting, onwheel, }; const typeToHandler = std.StaticStringMap(Handler).initComptime(blk: { const fields = std.meta.fields(Handler); var entries: [fields.len]struct { []const u8, Handler } = undefined; for (fields, 0..) |field, i| { entries[i] = .{ field.name[2..], @enumFromInt(field.value) }; } break :blk entries; }); pub fn fromEventType(typ: []const u8) ?Handler { return typeToHandler.get(typ); } const testing = @import("../../testing.zig"); test "GlobalEventHandlers: fromEventType" { try testing.expectEqual(.onabort, fromEventType("abort")); try testing.expectEqual(.onselect, fromEventType("select")); try testing.expectEqual(null, fromEventType("")); try testing.expectEqual(null, fromEventType("unknown")); } ================================================ FILE: src/browser/webapi/media/MediaError.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const MediaError = @This(); _code: u16, _message: []const u8 = "", pub fn init(code: u16, message: []const u8, page: *Page) !*MediaError { return page.arena.create(MediaError{ ._code = code, ._message = try page.dupeString(message), }); } pub fn getCode(self: *const MediaError) u16 { return self._code; } pub fn getMessage(self: *const MediaError) []const u8 { return self._message; } pub const JsApi = struct { pub const bridge = js.Bridge(MediaError); pub const Meta = struct { pub const name = "MediaError"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; // Error code constants pub const MEDIA_ERR_ABORTED = bridge.property(1, .{ .template = true }); pub const MEDIA_ERR_NETWORK = bridge.property(2, .{ .template = true }); pub const MEDIA_ERR_DECODE = bridge.property(3, .{ .template = true }); pub const MEDIA_ERR_SRC_NOT_SUPPORTED = bridge.property(4, .{ .template = true }); pub const code = bridge.accessor(MediaError.getCode, null, .{}); pub const message = bridge.accessor(MediaError.getMessage, null, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: MediaError" { try testing.htmlRunner("media/mediaerror.html", .{}); } ================================================ FILE: src/browser/webapi/media/TextTrackCue.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const EventTarget = @import("../EventTarget.zig"); const TextTrackCue = @This(); _type: Type, _proto: *EventTarget, _id: []const u8 = "", _start_time: f64 = 0, _end_time: f64 = 0, _pause_on_exit: bool = false, _on_enter: ?js.Function.Global = null, _on_exit: ?js.Function.Global = null, pub const Type = union(enum) { vtt: *@import("VTTCue.zig"), }; pub fn asEventTarget(self: *TextTrackCue) *EventTarget { return self._proto; } pub fn getId(self: *const TextTrackCue) []const u8 { return self._id; } pub fn setId(self: *TextTrackCue, value: []const u8, page: *Page) !void { self._id = try page.dupeString(value); } pub fn getStartTime(self: *const TextTrackCue) f64 { return self._start_time; } pub fn setStartTime(self: *TextTrackCue, value: f64) void { self._start_time = value; } pub fn getEndTime(self: *const TextTrackCue) f64 { return self._end_time; } pub fn setEndTime(self: *TextTrackCue, value: f64) void { self._end_time = value; } pub fn getPauseOnExit(self: *const TextTrackCue) bool { return self._pause_on_exit; } pub fn setPauseOnExit(self: *TextTrackCue, value: bool) void { self._pause_on_exit = value; } pub fn getOnEnter(self: *const TextTrackCue) ?js.Function.Global { return self._on_enter; } pub fn setOnEnter(self: *TextTrackCue, cb: ?js.Function.Global) !void { self._on_enter = cb; } pub fn getOnExit(self: *const TextTrackCue) ?js.Function.Global { return self._on_exit; } pub fn setOnExit(self: *TextTrackCue, cb: ?js.Function.Global) !void { self._on_exit = cb; } pub const JsApi = struct { pub const bridge = js.Bridge(TextTrackCue); pub const Meta = struct { pub const name = "TextTrackCue"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const Prototype = EventTarget; pub const id = bridge.accessor(TextTrackCue.getId, TextTrackCue.setId, .{}); pub const startTime = bridge.accessor(TextTrackCue.getStartTime, TextTrackCue.setStartTime, .{}); pub const endTime = bridge.accessor(TextTrackCue.getEndTime, TextTrackCue.setEndTime, .{}); pub const pauseOnExit = bridge.accessor(TextTrackCue.getPauseOnExit, TextTrackCue.setPauseOnExit, .{}); pub const onenter = bridge.accessor(TextTrackCue.getOnEnter, TextTrackCue.setOnEnter, .{}); pub const onexit = bridge.accessor(TextTrackCue.getOnExit, TextTrackCue.setOnExit, .{}); }; ================================================ FILE: src/browser/webapi/media/VTTCue.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const TextTrackCue = @import("TextTrackCue.zig"); const VTTCue = @This(); _proto: *TextTrackCue, _text: []const u8 = "", _region: ?js.Object.Global = null, _vertical: []const u8 = "", _snap_to_lines: bool = true, _line: ?f64 = null, // null represents "auto" _position: ?f64 = null, // null represents "auto" _size: f64 = 100, _align: []const u8 = "center", pub fn constructor(start_time: f64, end_time: f64, text: []const u8, page: *Page) !*VTTCue { const cue = try page._factory.textTrackCue(VTTCue{ ._proto = undefined, ._text = try page.dupeString(text), ._region = null, ._vertical = "", ._snap_to_lines = true, ._line = null, // "auto" ._position = null, // "auto" ._size = 100, ._align = "center", }); cue._proto._start_time = start_time; cue._proto._end_time = end_time; return cue; } pub fn asTextTrackCue(self: *VTTCue) *TextTrackCue { return self._proto; } pub fn getText(self: *const VTTCue) []const u8 { return self._text; } pub fn setText(self: *VTTCue, value: []const u8, page: *Page) !void { self._text = try page.dupeString(value); } pub fn getRegion(self: *const VTTCue) ?js.Object.Global { return self._region; } pub fn setRegion(self: *VTTCue, value: ?js.Object.Global) !void { self._region = value; } pub fn getVertical(self: *const VTTCue) []const u8 { return self._vertical; } pub fn setVertical(self: *VTTCue, value: []const u8, page: *Page) !void { // Valid values: "", "rl", "lr" self._vertical = try page.dupeString(value); } pub fn getSnapToLines(self: *const VTTCue) bool { return self._snap_to_lines; } pub fn setSnapToLines(self: *VTTCue, value: bool) void { self._snap_to_lines = value; } pub const LineAndPositionSetting = union(enum) { number: f64, auto: []const u8, }; pub fn getLine(self: *const VTTCue) LineAndPositionSetting { if (self._line) |num| { return .{ .number = num }; } return .{ .auto = "auto" }; } pub fn setLine(self: *VTTCue, value: LineAndPositionSetting) void { switch (value) { .number => |num| self._line = num, .auto => self._line = null, } } pub fn getPosition(self: *const VTTCue) LineAndPositionSetting { if (self._position) |num| { return .{ .number = num }; } return .{ .auto = "auto" }; } pub fn setPosition(self: *VTTCue, value: LineAndPositionSetting) void { switch (value) { .number => |num| self._position = num, .auto => self._position = null, } } pub fn getSize(self: *const VTTCue) f64 { return self._size; } pub fn setSize(self: *VTTCue, value: f64) void { self._size = value; } pub fn getAlign(self: *const VTTCue) []const u8 { return self._align; } pub fn setAlign(self: *VTTCue, value: []const u8, page: *Page) !void { // Valid values: "start", "center", "end", "left", "right" self._align = try page.dupeString(value); } pub fn getCueAsHTML(self: *const VTTCue, page: *Page) !js.Object { // Minimal implementation: return a document fragment // In a full implementation, this would parse the VTT text into HTML nodes _ = self; _ = page; return error.NotImplemented; } pub const JsApi = struct { pub const bridge = js.Bridge(VTTCue); pub const Meta = struct { pub const name = "VTTCue"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const Prototype = TextTrackCue; pub const constructor = bridge.constructor(VTTCue.constructor, .{}); pub const text = bridge.accessor(VTTCue.getText, VTTCue.setText, .{}); pub const region = bridge.accessor(VTTCue.getRegion, VTTCue.setRegion, .{}); pub const vertical = bridge.accessor(VTTCue.getVertical, VTTCue.setVertical, .{}); pub const snapToLines = bridge.accessor(VTTCue.getSnapToLines, VTTCue.setSnapToLines, .{}); pub const line = bridge.accessor(VTTCue.getLine, VTTCue.setLine, .{}); pub const position = bridge.accessor(VTTCue.getPosition, VTTCue.setPosition, .{}); pub const size = bridge.accessor(VTTCue.getSize, VTTCue.setSize, .{}); pub const @"align" = bridge.accessor(VTTCue.getAlign, VTTCue.setAlign, .{}); pub const getCueAsHTML = bridge.function(VTTCue.getCueAsHTML, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: VTTCue" { try testing.htmlRunner("media/vttcue.html", .{}); } ================================================ FILE: src/browser/webapi/navigation/Navigation.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const log = @import("../../../log.zig"); const URL = @import("../URL.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); const EventTarget = @import("../EventTarget.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; // https://developer.mozilla.org/en-US/docs/Web/API/Navigation const Navigation = @This(); const NavigationKind = @import("root.zig").NavigationKind; const NavigationActivation = @import("NavigationActivation.zig"); const NavigationTransition = @import("root.zig").NavigationTransition; const NavigationState = @import("root.zig").NavigationState; const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); const NavigationCurrentEntryChangeEvent = @import("../event/NavigationCurrentEntryChangeEvent.zig"); _proto: *EventTarget, _on_currententrychange: ?js.Function.Global = null, _current_navigation_kind: ?NavigationKind = null, _index: usize = 0, // Need to be stable pointers, because Events can reference entries. _entries: std.ArrayList(*NavigationHistoryEntry) = .empty, _next_entry_id: usize = 0, _activation: ?NavigationActivation = null, fn asEventTarget(self: *Navigation) *EventTarget { return self._proto; } pub fn onRemovePage(self: *Navigation) void { self._proto = undefined; } pub fn onNewPage(self: *Navigation, page: *Page) !void { self._proto = try page._factory.standaloneEventTarget(self); } pub fn getActivation(self: *const Navigation) ?NavigationActivation { return self._activation; } pub fn getCanGoBack(self: *const Navigation) bool { return self._index > 0; } pub fn getCanGoForward(self: *const Navigation) bool { return self._entries.items.len > self._index + 1; } pub fn getCurrentEntryOrNull(self: *Navigation) ?*NavigationHistoryEntry { if (self._entries.items.len > self._index) { return self._entries.items[self._index]; } else return null; } pub fn getCurrentEntry(self: *Navigation) *NavigationHistoryEntry { // This should never fail. An entry should always be created before // we run the scripts on the page we are loading. const len = self._entries.items.len; lp.assert(len > 0, "Navigation.getCurrentEntry", .{ .len = len }); return self.getCurrentEntryOrNull().?; } pub fn getTransition(_: *const Navigation) ?NavigationTransition { // For now, all transitions are just considered complete. return null; } const NavigationReturn = struct { committed: js.Promise.Global, finished: js.Promise.Global, }; pub fn back(self: *Navigation, page: *Page) !NavigationReturn { if (!self.getCanGoBack()) { return error.InvalidStateError; } const new_index = self._index - 1; const next_entry = self._entries.items[new_index]; return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); } pub fn entries(self: *const Navigation) []*NavigationHistoryEntry { return self._entries.items; } pub fn forward(self: *Navigation, page: *Page) !NavigationReturn { if (!self.getCanGoForward()) { return error.InvalidStateError; } const new_index = self._index + 1; const next_entry = self._entries.items[new_index]; return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); } pub fn updateEntries( self: *Navigation, url: [:0]const u8, kind: NavigationKind, page: *Page, should_dispatch: bool, ) !void { switch (kind) { .replace => |state| { _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, page, should_dispatch); }, .push => |state| { _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, should_dispatch); }, .traverse => |index| { self._index = index; }, .reload => {}, } } // This is for after true navigation processing, where we need to ensure that our entries are up to date. // // This is only really safe to run in the `pageDoneCallback` // where we can guarantee that the URL and NavigationKind are correct. pub fn commitNavigation(self: *Navigation, page: *Page) !void { const url = page.url; const kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null }; defer self._current_navigation_kind = null; const from_entry = self.getCurrentEntryOrNull(); try self.updateEntries(url, kind, page, false); self._activation = NavigationActivation{ ._from = from_entry, ._entry = self.getCurrentEntry(), ._type = kind.toNavigationType(), }; } /// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. /// For that, use `navigate`. pub fn pushEntry( self: *Navigation, _url: [:0]const u8, state: NavigationState, page: *Page, should_dispatch: bool, ) !*NavigationHistoryEntry { const arena = page._session.arena; const url = try arena.dupeZ(u8, _url); // truncates our history here. if (self._entries.items.len > self._index + 1) { self._entries.shrinkRetainingCapacity(self._index + 1); } const index = self._entries.items.len; const id = self._next_entry_id; self._next_entry_id += 1; const id_str = try std.fmt.allocPrint(arena, "{d}", .{id}); const entry = try arena.create(NavigationHistoryEntry); entry.* = NavigationHistoryEntry{ ._id = id_str, ._key = id_str, ._url = url, ._state = state, }; // we don't always have a current entry... const previous = if (self._entries.items.len > 0) self.getCurrentEntry() else null; try self._entries.append(arena, entry); self._index = index; if (previous == null or should_dispatch == false) { return entry; } if (self._on_currententrychange) |cec| { const event = (try NavigationCurrentEntryChangeEvent.initTrusted( .wrap("currententrychange"), .{ .from = previous.?, .navigationType = @tagName(.push) }, page, )).asEvent(); try self.dispatch(cec, event, page); } return entry; } pub fn replaceEntry( self: *Navigation, _url: [:0]const u8, state: NavigationState, page: *Page, should_dispatch: bool, ) !*NavigationHistoryEntry { const arena = page._session.arena; const url = try arena.dupeZ(u8, _url); const previous = self.getCurrentEntry(); const id = self._next_entry_id; self._next_entry_id += 1; const id_str = try std.fmt.allocPrint(arena, "{d}", .{id}); const entry = try arena.create(NavigationHistoryEntry); entry.* = NavigationHistoryEntry{ ._id = id_str, ._key = previous._key, ._url = url, ._state = state, }; self._entries.items[self._index] = entry; if (should_dispatch == false) { return entry; } if (self._on_currententrychange) |cec| { const event = (try NavigationCurrentEntryChangeEvent.initTrusted( .wrap("currententrychange"), .{ .from = previous, .navigationType = @tagName(.replace) }, page, )).asEvent(); try self.dispatch(cec, event, page); } return entry; } const NavigateOptions = struct { state: ?js.Value = null, info: ?js.Value = null, history: ?[]const u8 = null, }; pub fn navigateInner( self: *Navigation, _url: ?[:0]const u8, kind: NavigationKind, page: *Page, ) !NavigationReturn { const arena = page._session.arena; const url = _url orelse return error.MissingURL; // https://github.com/WICG/navigation-api/issues/95 // // These will only settle on same-origin navigation (mostly intended for SPAs). // It is fine (and expected) for these to not settle on cross-origin requests :) const local = page.js.local.?; const committed = local.createPromiseResolver(); const finished = local.createPromiseResolver(); var new_url = try URL.resolve(arena, page.url, url, .{}); const is_same_document = URL.eqlDocument(new_url, page.url); // In case of navigation to the same document, we force an url duplication. // Keeping the same url generates a crash during WPT test navigate-history-push-same-url.html. // When building a script's src, script's base and page url overlap. if (is_same_document) { new_url = try arena.dupeZ(u8, new_url); } const previous = self.getCurrentEntry(); switch (kind) { .push => |state| { if (is_same_document) { page.url = new_url; committed.resolve("navigation push", {}); // todo: Fire navigate event finished.resolve("navigation push", {}); _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, true); } else { try page.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = page }); } }, .replace => |state| { if (is_same_document) { page.url = new_url; committed.resolve("navigation replace", {}); // todo: Fire navigate event finished.resolve("navigation replace", {}); _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, page, true); } else { try page.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = page }); } }, .traverse => |index| { self._index = index; if (is_same_document) { page.url = new_url; committed.resolve("navigation traverse", {}); // todo: Fire navigate event finished.resolve("navigation traverse", {}); } else { try page.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = page }); } }, .reload => { try page.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = page }); }, } if (self._on_currententrychange) |cec| { // If we haven't navigated off, let us fire off an a currententrychange. const event = (try NavigationCurrentEntryChangeEvent.initTrusted( .wrap("currententrychange"), .{ .from = previous, .navigationType = @tagName(kind) }, page, )).asEvent(); try self.dispatch(cec, event, page); } _ = try committed.persist(); _ = try finished.persist(); return .{ .committed = try committed.promise().persist(), .finished = try finished.promise().persist(), }; } pub fn navigate(self: *Navigation, _url: [:0]const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { const arena = page._session.arena; const opts = _opts orelse NavigateOptions{}; const json = if (opts.state) |state| state.toJson(arena) catch return error.DataClone else null; const kind: NavigationKind = if (opts.history) |history| if (std.mem.eql(u8, "replace", history)) .{ .replace = json } else .{ .push = json } else .{ .push = json }; return try self.navigateInner(_url, kind, page); } pub const ReloadOptions = struct { state: ?js.Value = null, info: ?js.Value = null, }; pub fn reload(self: *Navigation, _opts: ?ReloadOptions, page: *Page) !NavigationReturn { const arena = page._session.arena; const opts = _opts orelse ReloadOptions{}; const entry = self.getCurrentEntry(); if (opts.state) |state| { const previous = entry; entry._state = .{ .source = .navigation, .value = state.toJson(arena) catch return error.DataClone }; const event = try NavigationCurrentEntryChangeEvent.initTrusted( .wrap("currententrychange"), .{ .from = previous, .navigationType = @tagName(.reload) }, page, ); try self.dispatch(.{ .currententrychange = event }, page); } return self.navigateInner(entry._url, .reload, page); } pub const TraverseToOptions = struct { info: ?js.Value = null, }; pub fn traverseTo(self: *Navigation, key: []const u8, _opts: ?TraverseToOptions, page: *Page) !NavigationReturn { if (_opts != null) { log.warn(.not_implemented, "Navigation.traverseTo", .{ .has_options = true }); } for (self._entries.items, 0..) |entry, i| { if (std.mem.eql(u8, key, entry._key)) { return try self.navigateInner(entry._url, .{ .traverse = i }, page); } } return error.InvalidStateError; } pub const UpdateCurrentEntryOptions = struct { state: js.Value, }; pub fn updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, page: *Page) !void { const arena = page._session.arena; const previous = self.getCurrentEntry(); self.getCurrentEntry()._state = .{ .source = .navigation, .value = options.state.toJson(arena) catch return error.DataClone, }; if (self._on_currententrychange) |cec| { const event = (try NavigationCurrentEntryChangeEvent.initTrusted( .wrap("currententrychange"), .{ .from = previous, .navigationType = null }, page, )).asEvent(); try self.dispatch(cec, event, page); } } pub fn dispatch(self: *Navigation, func: js.Function.Global, event: *Event, page: *Page) !void { return page._event_manager.dispatchDirect( self.asEventTarget(), event, func, .{ .context = "Navigation" }, ); } fn getOnCurrentEntryChange(self: *Navigation) ?js.Function.Global { return self._on_currententrychange; } pub fn setOnCurrentEntryChange(self: *Navigation, listener: ?js.Function) !void { if (listener) |listen| { self._on_currententrychange = try listen.persistWithThis(self); } else { self._on_currententrychange = null; } } pub const JsApi = struct { pub const bridge = js.Bridge(Navigation); pub const Meta = struct { pub const name = "Navigation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const activation = bridge.accessor(Navigation.getActivation, null, .{}); pub const canGoBack = bridge.accessor(Navigation.getCanGoBack, null, .{}); pub const canGoForward = bridge.accessor(Navigation.getCanGoForward, null, .{}); pub const currentEntry = bridge.accessor(Navigation.getCurrentEntry, null, .{}); pub const transition = bridge.accessor(Navigation.getTransition, null, .{}); pub const back = bridge.function(Navigation.back, .{ .dom_exception = true }); pub const entries = bridge.function(Navigation.entries, .{}); pub const forward = bridge.function(Navigation.forward, .{ .dom_exception = true }); pub const navigate = bridge.function(Navigation.navigate, .{ .dom_exception = true }); pub const traverseTo = bridge.function(Navigation.traverseTo, .{ .dom_exception = true }); pub const updateCurrentEntry = bridge.function(Navigation.updateCurrentEntry, .{ .dom_exception = true }); pub const oncurrententrychange = bridge.accessor( Navigation.getOnCurrentEntryChange, Navigation.setOnCurrentEntryChange, .{}, ); }; ================================================ FILE: src/browser/webapi/navigation/NavigationActivation.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const NavigationType = @import("root.zig").NavigationType; const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); // https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation const NavigationActivation = @This(); _entry: *NavigationHistoryEntry, _from: ?*NavigationHistoryEntry = null, _type: NavigationType, pub fn getEntry(self: *const NavigationActivation) *NavigationHistoryEntry { return self._entry; } pub fn getFrom(self: *const NavigationActivation) ?*NavigationHistoryEntry { return self._from; } pub fn getNavigationType(self: *const NavigationActivation) []const u8 { return @tagName(self._type); } pub const JsApi = struct { pub const bridge = js.Bridge(NavigationActivation); pub const Meta = struct { pub const name = "NavigationActivation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const entry = bridge.accessor(NavigationActivation.getEntry, null, .{}); pub const from = bridge.accessor(NavigationActivation.getFrom, null, .{}); pub const navigationType = bridge.accessor(NavigationActivation.getNavigationType, null, .{}); }; ================================================ FILE: src/browser/webapi/navigation/NavigationHistoryEntry.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const URL = @import("../URL.zig"); const EventTarget = @import("../EventTarget.zig"); const NavigationState = @import("root.zig").NavigationState; const Page = @import("../../Page.zig"); const js = @import("../../js/js.zig"); const NavigationHistoryEntry = @This(); // https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry // no proto for now // _proto: ?*EventTarget, _id: []const u8, _key: []const u8, _url: ?[:0]const u8, _state: NavigationState, pub fn id(self: *const NavigationHistoryEntry) []const u8 { return self._id; } pub fn index(self: *const NavigationHistoryEntry, page: *Page) i32 { const navigation = &page._session.navigation; for (navigation._entries.items, 0..) |entry, i| { if (std.mem.eql(u8, entry._id, self._id)) { return @intCast(i); } } return -1; } pub fn key(self: *const NavigationHistoryEntry) []const u8 { return self._key; } pub fn sameDocument(self: *const NavigationHistoryEntry, page: *Page) bool { const got_url = self._url orelse return false; return URL.eqlDocument(got_url, page.base()); } pub fn url(self: *const NavigationHistoryEntry) ?[:0]const u8 { return self._url; } pub const StateReturn = union(enum) { value: ?js.Value, undefined: void }; pub fn getState(self: *const NavigationHistoryEntry, page: *Page) !StateReturn { if (self._state.source == .navigation) { if (self._state.value) |value| { return .{ .value = try page.js.local.?.parseJSON(value) }; } } return .undefined; } pub const JsApi = struct { pub const bridge = js.Bridge(NavigationHistoryEntry); pub const Meta = struct { pub const name = "NavigationHistoryEntry"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const id = bridge.accessor(NavigationHistoryEntry.id, null, .{}); pub const index = bridge.accessor(NavigationHistoryEntry.index, null, .{}); pub const key = bridge.accessor(NavigationHistoryEntry.key, null, .{}); pub const sameDocument = bridge.accessor(NavigationHistoryEntry.sameDocument, null, .{}); pub const url = bridge.accessor(NavigationHistoryEntry.url, null, .{}); pub const getState = bridge.function(NavigationHistoryEntry.getState, .{}); }; ================================================ FILE: src/browser/webapi/navigation/root.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); pub const NavigationType = enum { push, replace, traverse, reload, }; pub const NavigationKind = union(NavigationType) { push: ?[]const u8, replace: ?[]const u8, traverse: usize, reload, pub fn toNavigationType(self: NavigationKind) NavigationType { return std.meta.activeTag(self); } }; pub const NavigationState = struct { source: enum { history, navigation }, value: ?[]const u8, }; // https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition pub const NavigationTransition = struct { finished: js.Promise.Global, from: NavigationHistoryEntry, navigation_type: NavigationType, }; ================================================ FILE: src/browser/webapi/net/Fetch.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const HttpClient = @import("../../HttpClient.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const URL = @import("../../URL.zig"); const Blob = @import("../Blob.zig"); const Request = @import("Request.zig"); const Response = @import("Response.zig"); const AbortSignal = @import("../AbortSignal.zig"); const DOMException = @import("../DOMException.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const Fetch = @This(); _page: *Page, _url: []const u8, _buf: std.ArrayList(u8), _response: *Response, _resolver: js.PromiseResolver.Global, _owns_response: bool, _signal: ?*AbortSignal, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); const resolver = page.js.local.?.createPromiseResolver(); if (request._signal) |signal| { if (signal._aborted) { resolver.reject("fetch aborted", DOMException.init("The operation was aborted.", "AbortError")); return resolver.promise(); } } if (std.mem.startsWith(u8, request._url, "blob:")) { return handleBlobUrl(request._url, resolver, page); } const response = try Response.init(null, .{ .status = 0 }, page); errdefer response.deinit(true, page._session); const fetch = try response._arena.create(Fetch); fetch.* = .{ ._page = page, ._buf = .empty, ._url = try response._arena.dupe(u8, request._url), ._resolver = try resolver.persist(), ._response = response, ._owns_response = true, ._signal = request._signal, }; const http_client = page._session.browser.http_client; var headers = try http_client.newHeaders(); if (request._headers) |h| { try h.populateHttpHeader(page.call_arena, &headers); } try page.headersForRequest(page.arena, request._url, &headers); if (comptime IS_DEBUG) { log.debug(.http, "fetch", .{ .url = request._url }); } try http_client.request(.{ .ctx = fetch, .url = request._url, .method = request._method, .frame_id = page._frame_id, .body = request._body, .headers = headers, .resource_type = .fetch, .cookie_jar = &page._session.cookie_jar, .notification = page._session.notification, .start_callback = httpStartCallback, .header_callback = httpHeaderDoneCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, .error_callback = httpErrorCallback, .shutdown_callback = httpShutdownCallback, }); return resolver.promise(); } fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js.Promise { const blob: *Blob = page.lookupBlobUrl(url) orelse { resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" }); return resolver.promise(); }; const response = try Response.init(null, .{ .status = 200 }, page); response._body = try response._arena.dupe(u8, blob._slice); response._url = try response._arena.dupeZ(u8, url); response._type = .basic; if (blob._mime.len > 0) { try response._headers.append("Content-Type", blob._mime, page); } const js_val = try page.js.local.?.zigValueToJs(response, .{}); resolver.resolve("fetch blob done", js_val); return resolver.promise(); } fn httpStartCallback(transfer: *HttpClient.Transfer) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); if (comptime IS_DEBUG) { log.debug(.http, "request start", .{ .url = self._url, .source = "fetch" }); } self._response._transfer = transfer; } fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); if (self._signal) |signal| { if (signal._aborted) { return false; } } const arena = self._response._arena; if (transfer.getContentLength()) |cl| { try self._buf.ensureTotalCapacity(arena, cl); } const res = self._response; const header = transfer.response_header.?; if (comptime IS_DEBUG) { log.debug(.http, "request header", .{ .source = "fetch", .url = self._url, .status = header.status, }); } res._status = header.status; res._status_text = std.http.Status.phrase(@enumFromInt(header.status)) orelse ""; res._url = try arena.dupeZ(u8, std.mem.span(header.url)); res._is_redirected = header.redirect_count > 0; // Determine response type based on origin comparison const page_origin = URL.getOrigin(arena, self._page.url) catch null; const response_origin = URL.getOrigin(arena, res._url) catch null; if (page_origin) |po| { if (response_origin) |ro| { if (std.mem.eql(u8, po, ro)) { res._type = .basic; // Same-origin } else { res._type = .cors; // Cross-origin (for simplicity, assume CORS passed) } } else { res._type = .basic; } } else { res._type = .basic; } var it = transfer.responseHeaderIterator(); while (it.next()) |hdr| { try res._headers.append(hdr.name, hdr.value, self._page); } return true; } fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); // Check if aborted if (self._signal) |signal| { if (signal._aborted) { return error.Abort; } } try self._buf.appendSlice(self._response._arena, data); } fn httpDoneCallback(ctx: *anyopaque) !void { const self: *Fetch = @ptrCast(@alignCast(ctx)); var response = self._response; response._transfer = null; response._body = self._buf.items; log.info(.http, "request complete", .{ .source = "fetch", .url = self._url, .status = response._status, .len = self._buf.items.len, }); var ls: js.Local.Scope = undefined; self._page.js.localScope(&ls); defer ls.deinit(); const js_val = try ls.local.zigValueToJs(self._response, .{}); self._owns_response = false; return ls.toLocal(self._resolver).resolve("fetch done", js_val); } fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { const self: *Fetch = @ptrCast(@alignCast(ctx)); var response = self._response; response._transfer = null; // the response is only passed on v8 on success, if we're here, it's safe to // clear this. (defer since `self is in the response's arena). defer if (self._owns_response) { response.deinit(err == error.Abort, self._page._session); self._owns_response = false; }; var ls: js.Local.Scope = undefined; self._page.js.localScope(&ls); defer ls.deinit(); // fetch() must reject with a TypeError on network errors per spec ls.toLocal(self._resolver).rejectError("fetch error", .{ .type_error = @errorName(err) }); } fn httpShutdownCallback(ctx: *anyopaque) void { const self: *Fetch = @ptrCast(@alignCast(ctx)); if (comptime IS_DEBUG) { // should always be true std.debug.assert(self._owns_response); } if (self._owns_response) { var response = self._response; response._transfer = null; response.deinit(true, self._page._session); // Do not access `self` after this point: the Fetch struct was // allocated from response._arena which has been released. } } const testing = @import("../../../testing.zig"); test "WebApi: fetch" { try testing.htmlRunner("net/fetch.html", .{}); } ================================================ FILE: src/browser/webapi/net/FormData.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Form = @import("../element/html/Form.zig"); const Element = @import("../Element.zig"); const KeyValueList = @import("../KeyValueList.zig"); const Allocator = std.mem.Allocator; const FormData = @This(); _arena: Allocator, _list: KeyValueList, pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData { return page._factory.create(FormData{ ._arena = page.arena, ._list = try collectForm(page.arena, form, submitter, page), }); } pub fn get(self: *const FormData, name: []const u8) ?[]const u8 { return self._list.get(name); } pub fn getAll(self: *const FormData, name: []const u8, page: *Page) ![]const []const u8 { return self._list.getAll(name, page); } pub fn has(self: *const FormData, name: []const u8) bool { return self._list.has(name); } pub fn set(self: *FormData, name: []const u8, value: []const u8) !void { return self._list.set(self._arena, name, value); } pub fn append(self: *FormData, name: []const u8, value: []const u8) !void { return self._list.append(self._arena, name, value); } pub fn delete(self: *FormData, name: []const u8) void { self._list.delete(name, null); } pub fn keys(self: *FormData, page: *Page) !*KeyValueList.KeyIterator { return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page); } pub fn values(self: *FormData, page: *Page) !*KeyValueList.ValueIterator { return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page); } pub fn entries(self: *FormData, page: *Page) !*KeyValueList.EntryIterator { return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page); } pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void { const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_; for (self._list._entries.items) |entry| { cb.call(void, .{ entry.value.str(), entry.name.str(), self }) catch |err| { // this is a non-JS error log.warn(.js, "FormData.forEach", .{ .err = err }); }; } } pub fn write(self: *const FormData, encoding_: ?[]const u8, writer: *std.Io.Writer) !void { const encoding = encoding_ orelse { return self._list.urlEncode(.form, writer); }; if (std.ascii.eqlIgnoreCase(encoding, "application/x-www-form-urlencoded")) { return self._list.urlEncode(.form, writer); } log.warn(.not_implemented, "FormData.encoding", .{ .encoding = encoding, }); } pub const Iterator = struct { index: u32 = 0, list: *const FormData, const Entry = struct { []const u8, []const u8 }; pub fn next(self: *Iterator, _: *Page) !?Iterator.Entry { const index = self.index; const items = self.list._list.items(); if (index >= items.len) { return null; } self.index = index + 1; const e = &items[index]; return .{ e.name.str(), e.value.str() }; } }; fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Page) !KeyValueList { var list: KeyValueList = .empty; const form = form_ orelse return list; const form_node = form.asNode(); var elements = try form.getElements(page); var it = try elements.iterator(); while (it.next()) |element| { if (element.getAttributeSafe(comptime .wrap("disabled")) != null) { continue; } if (isDisabledByFieldset(element, form_node)) { continue; } // Handle image submitters first - they can submit without a name if (element.is(Form.Input)) |input| { if (input._input_type == .image) { const submitter = submitter_ orelse continue; if (submitter != element) { continue; } const name = element.getAttributeSafe(comptime .wrap("name")); const x_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.x", .{n}) else "x"; const y_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.y", .{n}) else "y"; try list.append(arena, x_key, "0"); try list.append(arena, y_key, "0"); continue; } } const name = element.getAttributeSafe(comptime .wrap("name")) orelse continue; const value = blk: { if (element.is(Form.Input)) |input| { const input_type = input._input_type; if (input_type == .checkbox or input_type == .radio) { if (!input.getChecked()) { continue; } } if (input_type == .submit) { const submitter = submitter_ orelse continue; if (submitter != element) { continue; } } break :blk input.getValue(); } if (element.is(Form.Select)) |select| { if (select.getMultiple() == false) { break :blk select.getValue(page); } var options = try select.getSelectedOptions(page); while (options.next()) |option| { try list.append(arena, name, option.as(Form.Select.Option).getValue(page)); } continue; } if (element.is(Form.TextArea)) |textarea| { break :blk textarea.getValue(); } if (submitter_) |submitter| { if (submitter == element) { // The form iterator only yields form controls. If we're here // all other control types have been handled. So the cast is safe. break :blk element.as(Form.Button).getValue(); } } continue; }; try list.append(arena, name, value); } return list; } // Returns true if `element` is disabled by an ancestor <fieldset disabled>, // stopping the upward walk when the form node is reached. // Per spec, elements inside the first <legend> child of a disabled fieldset // are NOT disabled by that fieldset. fn isDisabledByFieldset(element: *Element, form_node: *Node) bool { const element_node = element.asNode(); var current: ?*Node = element_node._parent; while (current) |node| { // Stop at the form boundary (common case optimisation) if (node == form_node) { return false; } current = node._parent; const el = node.is(Element) orelse continue; if (el.getTag() == .fieldset and el.getAttributeSafe(comptime .wrap("disabled")) != null) { // Check if `element` is inside the first <legend> child of this fieldset var child = el.firstElementChild(); while (child) |c| { if (c.getTag() == .legend) { // Found the first legend; exempt if element is a descendant if (c.asNode().contains(element_node)) { return false; } break; } child = c.nextElementSibling(); } return true; } } return false; } pub const JsApi = struct { pub const bridge = js.Bridge(FormData); pub const Meta = struct { pub const name = "FormData"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(FormData.init, .{}); pub const has = bridge.function(FormData.has, .{}); pub const get = bridge.function(FormData.get, .{}); pub const set = bridge.function(FormData.set, .{}); pub const append = bridge.function(FormData.append, .{}); pub const getAll = bridge.function(FormData.getAll, .{}); pub const delete = bridge.function(FormData.delete, .{}); pub const keys = bridge.function(FormData.keys, .{}); pub const values = bridge.function(FormData.values, .{}); pub const entries = bridge.function(FormData.entries, .{}); pub const symbol_iterator = bridge.iterator(FormData.entries, .{}); pub const forEach = bridge.function(FormData.forEach, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: FormData" { try testing.htmlRunner("net/form_data.html", .{}); } ================================================ FILE: src/browser/webapi/net/Headers.zig ================================================ const std = @import("std"); const js = @import("../../js/js.zig"); const log = @import("../../../log.zig"); const Page = @import("../../Page.zig"); const KeyValueList = @import("../KeyValueList.zig"); const Allocator = std.mem.Allocator; const Headers = @This(); _list: KeyValueList, pub const InitOpts = union(enum) { obj: *Headers, strings: []const [2][]const u8, js_obj: js.Object, }; pub fn init(opts_: ?InitOpts, page: *Page) !*Headers { const list = if (opts_) |opts| switch (opts) { .obj => |obj| try KeyValueList.copy(page.arena, obj._list), .js_obj => |js_obj| try KeyValueList.fromJsObject(page.arena, js_obj, normalizeHeaderName, page), .strings => |kvs| try KeyValueList.fromArray(page.arena, kvs, normalizeHeaderName, page), } else KeyValueList.init(); return page._factory.create(Headers{ ._list = list, }); } pub fn append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { const normalized_name = normalizeHeaderName(name, page); try self._list.append(page.arena, normalized_name, value); } pub fn delete(self: *Headers, name: []const u8, page: *Page) void { const normalized_name = normalizeHeaderName(name, page); self._list.delete(normalized_name, null); } pub fn get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { const normalized_name = normalizeHeaderName(name, page); const all_values = try self._list.getAll(normalized_name, page); if (all_values.len == 0) { return null; } if (all_values.len == 1) { return all_values[0]; } return try std.mem.join(page.call_arena, ", ", all_values); } pub fn has(self: *const Headers, name: []const u8, page: *Page) bool { const normalized_name = normalizeHeaderName(name, page); return self._list.has(normalized_name); } pub fn set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { const normalized_name = normalizeHeaderName(name, page); try self._list.set(page.arena, normalized_name, value); } pub fn keys(self: *Headers, page: *Page) !*KeyValueList.KeyIterator { return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, page); } pub fn values(self: *Headers, page: *Page) !*KeyValueList.ValueIterator { return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, page); } pub fn entries(self: *Headers, page: *Page) !*KeyValueList.EntryIterator { return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, page); } pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_; for (self._list._entries.items) |entry| { var caught: js.TryCatch.Caught = undefined; cb.tryCall(void, .{ entry.value.str(), entry.name.str(), self }, &caught) catch { log.debug(.js, "forEach callback", .{ .caught = caught, .source = "headers" }); }; } } // TODO: do we really need 2 different header structs?? const net_http = @import("../../../network/http.zig"); pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *net_http.Headers) !void { for (self._list._entries.items) |entry| { const merged = try std.mem.concatWithSentinel(allocator, u8, &.{ entry.name.str(), ": ", entry.value.str() }, 0); try http_headers.add(merged); } } fn normalizeHeaderName(name: []const u8, page: *Page) []const u8 { if (name.len > page.buf.len) { return name; } return std.ascii.lowerString(&page.buf, name); } pub const JsApi = struct { pub const bridge = js.Bridge(Headers); pub const Meta = struct { pub const name = "Headers"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(Headers.init, .{}); pub const append = bridge.function(Headers.append, .{}); pub const delete = bridge.function(Headers.delete, .{}); pub const get = bridge.function(Headers.get, .{}); pub const has = bridge.function(Headers.has, .{}); pub const set = bridge.function(Headers.set, .{}); pub const keys = bridge.function(Headers.keys, .{}); pub const values = bridge.function(Headers.values, .{}); pub const entries = bridge.function(Headers.entries, .{}); pub const forEach = bridge.function(Headers.forEach, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: Headers" { try testing.htmlRunner("net/headers.html", .{}); } ================================================ FILE: src/browser/webapi/net/Request.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const net_http = @import("../../../network/http.zig"); const URL = @import("../URL.zig"); const Page = @import("../../Page.zig"); const Headers = @import("Headers.zig"); const Blob = @import("../Blob.zig"); const AbortSignal = @import("../AbortSignal.zig"); const Allocator = std.mem.Allocator; const Request = @This(); _url: [:0]const u8, _method: net_http.Method, _headers: ?*Headers, _body: ?[]const u8, _arena: Allocator, _cache: Cache, _credentials: Credentials, _signal: ?*AbortSignal, pub const Input = union(enum) { request: *Request, url: [:0]const u8, }; pub const InitOpts = struct { method: ?[]const u8 = null, headers: ?Headers.InitOpts = null, body: ?[]const u8 = null, cache: Cache = .default, credentials: Credentials = .@"same-origin", signal: ?*AbortSignal = null, }; const Credentials = enum { omit, include, @"same-origin", pub const js_enum_from_string = true; }; const Cache = enum { default, @"no-store", reload, @"no-cache", @"force-cache", @"only-if-cached", pub const js_enum_from_string = true; }; pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { const arena = page.arena; const url = switch (input) { .url => |u| try URL.resolve(arena, page.base(), u, .{ .always_dupe = true }), .request => |r| try arena.dupeZ(u8, r._url), }; const opts = opts_ orelse InitOpts{}; const method = if (opts.method) |m| try parseMethod(m, page) else switch (input) { .url => .GET, .request => |r| r._method, }; const headers = if (opts.headers) |headers_init| switch (headers_init) { .obj => |h| h, else => try Headers.init(headers_init, page), } else switch (input) { .url => null, .request => |r| r._headers, }; const body = if (opts.body) |b| try arena.dupe(u8, b) else switch (input) { .url => null, .request => |r| r._body, }; const signal = if (opts.signal) |s| s else switch (input) { .url => null, .request => |r| r._signal, }; return page._factory.create(Request{ ._url = url, ._arena = arena, ._method = method, ._headers = headers, ._cache = opts.cache, ._credentials = opts.credentials, ._body = body, ._signal = signal, }); } fn parseMethod(method: []const u8, page: *Page) !net_http.Method { if (method.len > "propfind".len) { return error.InvalidMethod; } const lower = std.ascii.lowerString(&page.buf, method); const method_lookup = std.StaticStringMap(net_http.Method).initComptime(.{ .{ "get", .GET }, .{ "post", .POST }, .{ "delete", .DELETE }, .{ "put", .PUT }, .{ "patch", .PATCH }, .{ "head", .HEAD }, .{ "options", .OPTIONS }, .{ "propfind", .PROPFIND }, }); return method_lookup.get(lower) orelse return error.InvalidMethod; } pub fn getUrl(self: *const Request) []const u8 { return self._url; } pub fn getMethod(self: *const Request) []const u8 { return @tagName(self._method); } pub fn getCache(self: *const Request) []const u8 { return @tagName(self._cache); } pub fn getCredentials(self: *const Request) []const u8 { return @tagName(self._credentials); } pub fn getSignal(self: *const Request) ?*AbortSignal { return self._signal; } pub fn getHeaders(self: *Request, page: *Page) !*Headers { if (self._headers) |headers| { return headers; } const headers = try Headers.init(null, page); self._headers = headers; return headers; } pub fn blob(self: *Request, page: *Page) !js.Promise { const body = self._body orelse ""; const headers = try self.getHeaders(page); const content_type = try headers.get("content-type", page) orelse ""; const b = try Blob.initWithMimeValidation( &.{body}, .{ .type = content_type }, true, page, ); return page.js.local.?.resolvePromise(b); } pub fn text(self: *const Request, page: *Page) !js.Promise { const body = self._body orelse ""; return page.js.local.?.resolvePromise(body); } pub fn json(self: *const Request, page: *Page) !js.Promise { const body = self._body orelse ""; const local = page.js.local.?; const value = local.parseJSON(body) catch |err| { return local.rejectPromise(.{@errorName(err)}); }; return local.resolvePromise(try value.persist()); } pub fn arrayBuffer(self: *const Request, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._body orelse "" }); } pub fn bytes(self: *const Request, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(js.TypedArray(u8){ .values = self._body orelse "" }); } pub fn clone(self: *const Request, page: *Page) !*Request { return page._factory.create(Request{ ._url = self._url, ._arena = self._arena, ._method = self._method, ._headers = self._headers, ._cache = self._cache, ._credentials = self._credentials, ._body = self._body, ._signal = self._signal, }); } pub const JsApi = struct { pub const bridge = js.Bridge(Request); pub const Meta = struct { pub const name = "Request"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(Request.init, .{}); pub const url = bridge.accessor(Request.getUrl, null, .{}); pub const method = bridge.accessor(Request.getMethod, null, .{}); pub const headers = bridge.accessor(Request.getHeaders, null, .{}); pub const cache = bridge.accessor(Request.getCache, null, .{}); pub const credentials = bridge.accessor(Request.getCredentials, null, .{}); pub const signal = bridge.accessor(Request.getSignal, null, .{}); pub const blob = bridge.function(Request.blob, .{}); pub const text = bridge.function(Request.text, .{}); pub const json = bridge.function(Request.json, .{}); pub const arrayBuffer = bridge.function(Request.arrayBuffer, .{}); pub const bytes = bridge.function(Request.bytes, .{}); pub const clone = bridge.function(Request.clone, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: Request" { try testing.htmlRunner("net/request.html", .{}); } ================================================ FILE: src/browser/webapi/net/Response.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const HttpClient = @import("../../HttpClient.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Headers = @import("Headers.zig"); const ReadableStream = @import("../streams/ReadableStream.zig"); const Blob = @import("../Blob.zig"); const Allocator = std.mem.Allocator; const Response = @This(); pub const Type = enum { basic, cors, @"error", @"opaque", opaqueredirect, }; _status: u16, _arena: Allocator, _headers: *Headers, _body: ?[]const u8, _type: Type, _status_text: []const u8, _url: [:0]const u8, _is_redirected: bool, _transfer: ?*HttpClient.Transfer = null, const InitOpts = struct { status: u16 = 200, headers: ?Headers.InitOpts = null, statusText: ?[]const u8 = null, }; pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { const arena = try page.getArena(.{ .debug = "Response" }); errdefer page.releaseArena(arena); const opts = opts_ orelse InitOpts{}; // Store empty string as empty string, not null const body = if (body_) |b| try arena.dupe(u8, b) else null; const status_text = if (opts.statusText) |st| try arena.dupe(u8, st) else ""; const self = try arena.create(Response); self.* = .{ ._arena = arena, ._status = opts.status, ._status_text = status_text, ._url = "", ._body = body, ._type = .basic, ._is_redirected = false, ._headers = try Headers.init(opts.headers, page), }; return self; } pub fn deinit(self: *Response, shutdown: bool, session: *Session) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); } else { transfer.abort(error.Abort); } self._transfer = null; } session.releaseArena(self._arena); } pub fn getStatus(self: *const Response) u16 { return self._status; } pub fn getStatusText(self: *const Response) []const u8 { return self._status_text; } pub fn getURL(self: *const Response) []const u8 { return self._url; } pub fn isRedirected(self: *const Response) bool { return self._is_redirected; } pub fn getHeaders(self: *const Response) *Headers { return self._headers; } pub fn getType(self: *const Response) []const u8 { return @tagName(self._type); } pub fn getBody(self: *const Response, page: *Page) !?*ReadableStream { const body = self._body orelse return null; // Empty string should create a closed stream with no data if (body.len == 0) { const stream = try ReadableStream.init(null, null, page); try stream._controller.close(); return stream; } return ReadableStream.initWithData(body, page); } pub fn isOK(self: *const Response) bool { return self._status >= 200 and self._status <= 299; } pub fn getText(self: *const Response, page: *Page) !js.Promise { const body = self._body orelse ""; return page.js.local.?.resolvePromise(body); } pub fn getJson(self: *Response, page: *Page) !js.Promise { const body = self._body orelse ""; const local = page.js.local.?; const value = local.parseJSON(body) catch |err| { return local.rejectPromise(.{@errorName(err)}); }; return local.resolvePromise(try value.persist()); } pub fn arrayBuffer(self: *const Response, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._body orelse "" }); } pub fn blob(self: *const Response, page: *Page) !js.Promise { const body = self._body orelse ""; const content_type = try self._headers.get("content-type", page) orelse ""; const b = try Blob.initWithMimeValidation( &.{body}, .{ .type = content_type }, true, page, ); return page.js.local.?.resolvePromise(b); } pub fn bytes(self: *const Response, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(js.TypedArray(u8){ .values = self._body orelse "" }); } pub fn clone(self: *const Response, page: *Page) !*Response { const arena = try page.getArena(.{ .debug = "Response.clone" }); errdefer page.releaseArena(arena); const body = if (self._body) |b| try arena.dupe(u8, b) else null; const status_text = try arena.dupe(u8, self._status_text); const url = try arena.dupeZ(u8, self._url); const cloned = try arena.create(Response); cloned.* = .{ ._arena = arena, ._status = self._status, ._status_text = status_text, ._url = url, ._body = body, ._type = self._type, ._is_redirected = self._is_redirected, ._headers = try Headers.init(.{ .obj = self._headers }, page), ._transfer = null, }; return cloned; } pub const JsApi = struct { pub const bridge = js.Bridge(Response); pub const Meta = struct { pub const name = "Response"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(Response.deinit); }; pub const constructor = bridge.constructor(Response.init, .{}); pub const ok = bridge.accessor(Response.isOK, null, .{}); pub const status = bridge.accessor(Response.getStatus, null, .{}); pub const statusText = bridge.accessor(Response.getStatusText, null, .{}); pub const @"type" = bridge.accessor(Response.getType, null, .{}); pub const text = bridge.function(Response.getText, .{}); pub const json = bridge.function(Response.getJson, .{}); pub const headers = bridge.accessor(Response.getHeaders, null, .{}); pub const body = bridge.accessor(Response.getBody, null, .{}); pub const url = bridge.accessor(Response.getURL, null, .{}); pub const redirected = bridge.accessor(Response.isRedirected, null, .{}); pub const arrayBuffer = bridge.function(Response.arrayBuffer, .{}); pub const blob = bridge.function(Response.blob, .{}); pub const bytes = bridge.function(Response.bytes, .{}); pub const clone = bridge.function(Response.clone, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: Response" { try testing.htmlRunner("net/response.html", .{}); } ================================================ FILE: src/browser/webapi/net/URLSearchParams.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const log = @import("../../../log.zig"); const String = @import("../../../string.zig").String; const Allocator = std.mem.Allocator; const Page = @import("../../Page.zig"); const FormData = @import("FormData.zig"); const KeyValueList = @import("../KeyValueList.zig"); const URLSearchParams = @This(); _arena: Allocator, _params: KeyValueList, const InitOpts = union(enum) { form_data: *FormData, value: js.Value, query_string: []const u8, }; pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams { const arena = page.arena; const params: KeyValueList = blk: { const opts = opts_ orelse break :blk .empty; switch (opts) { .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), .form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list), .value => |js_val| { if (js_val.isObject()) { break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page); } if (js_val.isString()) |js_str| { break :blk try paramsFromString(arena, try js_str.toSliceWithAlloc(arena), &page.buf); } return error.InvalidArgument; }, } }; return page._factory.create(URLSearchParams{ ._arena = arena, ._params = params, }); } pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, page: *Page) !void { self._params = try paramsFromString(self._arena, query_string, &page.buf); } pub fn getSize(self: *const URLSearchParams) usize { return self._params.len(); } pub fn get(self: *const URLSearchParams, name: []const u8) ?[]const u8 { return self._params.get(name); } pub fn getAll(self: *const URLSearchParams, name: []const u8, page: *Page) ![]const []const u8 { return self._params.getAll(name, page); } pub fn has(self: *const URLSearchParams, name: []const u8) bool { return self._params.has(name); } pub fn set(self: *URLSearchParams, name: []const u8, value: []const u8) !void { return self._params.set(self._arena, name, value); } pub fn append(self: *URLSearchParams, name: []const u8, value: []const u8) !void { return self._params.append(self._arena, name, value); } pub fn delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) void { self._params.delete(name, value); } pub fn keys(self: *URLSearchParams, page: *Page) !*KeyValueList.KeyIterator { return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, page); } pub fn values(self: *URLSearchParams, page: *Page) !*KeyValueList.ValueIterator { return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, page); } pub fn entries(self: *URLSearchParams, page: *Page) !*KeyValueList.EntryIterator { return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, page); } pub fn toString(self: *const URLSearchParams, writer: *std.Io.Writer) !void { return self._params.urlEncode(.query, writer); } pub fn format(self: *const URLSearchParams, writer: *std.Io.Writer) !void { return self.toString(writer); } pub fn forEach(self: *URLSearchParams, cb_: js.Function, js_this_: ?js.Object) !void { const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_; for (self._params._entries.items) |entry| { cb.call(void, .{ entry.value.str(), entry.name.str(), self }) catch |err| { // this is a non-JS error log.warn(.js, "URLSearchParams.forEach", .{ .err = err }); }; } } pub fn sort(self: *URLSearchParams) void { std.mem.sort(KeyValueList.Entry, self._params._entries.items, {}, struct { fn cmp(_: void, a: KeyValueList.Entry, b: KeyValueList.Entry) bool { return std.mem.order(u8, a.name.str(), b.name.str()) == .lt; } }.cmp); } fn paramsFromString(allocator: Allocator, input_: []const u8, buf: []u8) !KeyValueList { if (input_.len == 0) { return .empty; } var input = input_; if (input[0] == '?') { input = input[1..]; } // After stripping '?', check if string is empty if (input.len == 0) { return .empty; } var params = KeyValueList.init(); var it = std.mem.splitScalar(u8, input, '&'); while (it.next()) |entry| { // Skip empty entries (from trailing &, or &&) if (entry.len == 0) continue; var name: String = undefined; var value: String = undefined; if (std.mem.indexOfScalarPos(u8, entry, 0, '=')) |idx| { name = try unescape(allocator, entry[0..idx], buf); value = try unescape(allocator, entry[idx + 1 ..], buf); } else { name = try unescape(allocator, entry, buf); value = String.init(undefined, "", .{}) catch unreachable; } // optimized, unescape returns a String directly (Because unescape may // have to dupe itself, so it knows how best to create the String) try params._entries.append(allocator, .{ .name = name, .value = value, }); } return params; } fn unescape(arena: Allocator, value: []const u8, buf: []u8) !String { if (value.len == 0) { return String.init(undefined, "", .{}); } var has_plus = false; var unescaped_len = value.len; var in_i: usize = 0; while (in_i < value.len) { const b = value[in_i]; if (b == '%') { if (in_i + 2 >= value.len or !std.ascii.isHex(value[in_i + 1]) or !std.ascii.isHex(value[in_i + 2])) { return error.InvalidEscapeSequence; } in_i += 3; unescaped_len -= 2; } else if (b == '+') { has_plus = true; in_i += 1; } else { in_i += 1; } } // no encoding, and no plus. nothing to unescape if (unescaped_len == value.len and !has_plus) { return String.init(arena, value, .{}); } var out = buf; var duped = false; if (buf.len < unescaped_len) { out = try arena.alloc(u8, unescaped_len); duped = true; } in_i = 0; for (0..unescaped_len) |i| { const b = value[in_i]; if (b == '%') { out[i] = decodeHex(value[in_i + 1]) << 4 | decodeHex(value[in_i + 2]); in_i += 3; } else if (b == '+') { out[i] = ' '; in_i += 1; } else { out[i] = b; in_i += 1; } } return String.init(arena, out[0..unescaped_len], .{ .dupe = !duped }); } const HEX_DECODE_ARRAY = blk: { var all: ['f' - '0' + 1]u8 = undefined; for ('0'..('9' + 1)) |b| all[b - '0'] = b - '0'; for ('A'..('F' + 1)) |b| all[b - '0'] = b - 'A' + 10; for ('a'..('f' + 1)) |b| all[b - '0'] = b - 'a' + 10; break :blk all; }; inline fn decodeHex(char: u8) u8 { return @as([*]const u8, @ptrFromInt((@intFromPtr(&HEX_DECODE_ARRAY) - @as(usize, '0'))))[char]; } fn escape(input: []const u8, writer: *std.Io.Writer) !void { for (input) |c| { if (isUnreserved(c)) { try writer.writeByte(c); } else if (c == ' ') { try writer.writeByte('+'); } else if (c == '*') { try writer.writeByte('*'); } else if (c >= 0x80) { // Double-encode: treat byte as Latin-1 code point, encode to UTF-8, then percent-encode // For bytes 0x80-0xFF (U+0080 to U+00FF), UTF-8 encoding is 2 bytes: // [0xC0 | (c >> 6), 0x80 | (c & 0x3F)] const byte1 = 0xC0 | (c >> 6); const byte2 = 0x80 | (c & 0x3F); try writer.print("%{X:0>2}%{X:0>2}", .{ byte1, byte2 }); } else { try writer.print("%{X:0>2}", .{c}); } } } fn isUnreserved(c: u8) bool { return switch (c) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => true, else => false, }; } pub const Iterator = struct { index: u32 = 0, list: *const URLSearchParams, const Entry = struct { []const u8, []const u8 }; pub fn next(self: *Iterator, _: *Page) !?Iterator.Entry { const index = self.index; const items = self.list._params.items; if (index >= items.len) { return null; } self.index = index + 1; const e = &items[index]; return .{ e.name.str(), e.value.str() }; } }; pub const JsApi = struct { pub const bridge = js.Bridge(URLSearchParams); pub const Meta = struct { pub const name = "URLSearchParams"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(URLSearchParams.init, .{}); pub const has = bridge.function(URLSearchParams.has, .{}); pub const get = bridge.function(URLSearchParams.get, .{}); pub const set = bridge.function(URLSearchParams.set, .{}); pub const append = bridge.function(URLSearchParams.append, .{}); pub const getAll = bridge.function(URLSearchParams.getAll, .{}); pub const delete = bridge.function(URLSearchParams.delete, .{}); pub const size = bridge.accessor(URLSearchParams.getSize, null, .{}); pub const keys = bridge.function(URLSearchParams.keys, .{}); pub const values = bridge.function(URLSearchParams.values, .{}); pub const entries = bridge.function(URLSearchParams.entries, .{}); pub const symbol_iterator = bridge.iterator(URLSearchParams.entries, .{}); pub const forEach = bridge.function(URLSearchParams.forEach, .{}); pub const sort = bridge.function(URLSearchParams.sort, .{}); pub const toString = bridge.function(_toString, .{}); fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.toString(&buf.writer); return buf.written(); } }; const testing = @import("../../../testing.zig"); test "WebApi: URLSearchParams" { try testing.htmlRunner("net/url_search_params.html", .{}); } ================================================ FILE: src/browser/webapi/net/XMLHttpRequest.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const log = @import("../../../log.zig"); const HttpClient = @import("../../HttpClient.zig"); const net_http = @import("../../../network/http.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const Blob = @import("../Blob.zig"); const Event = @import("../Event.zig"); const Headers = @import("Headers.zig"); const EventTarget = @import("../EventTarget.zig"); const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig"); const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; const XMLHttpRequest = @This(); _page: *Page, _proto: *XMLHttpRequestEventTarget, _arena: Allocator, _transfer: ?*HttpClient.Transfer = null, _url: [:0]const u8 = "", _method: net_http.Method = .GET, _request_headers: *Headers, _request_body: ?[]const u8 = null, _response: ?Response = null, _response_data: std.ArrayList(u8) = .empty, _response_status: u16 = 0, _response_len: ?usize = 0, _response_url: [:0]const u8 = "", _response_mime: ?Mime = null, _response_headers: std.ArrayList([]const u8) = .empty, _response_type: ResponseType = .text, _ready_state: ReadyState = .unsent, _on_ready_state_change: ?js.Function.Temp = null, _with_credentials: bool = false, const ReadyState = enum(u8) { unsent = 0, opened = 1, headers_received = 2, loading = 3, done = 4, }; const Response = union(ResponseType) { text: []const u8, json: js.Value.Global, document: *Node.Document, arraybuffer: js.ArrayBuffer, }; const ResponseType = enum { text, json, document, arraybuffer, // TODO: other types to support }; pub fn init(page: *Page) !*XMLHttpRequest { const arena = try page.getArena(.{ .debug = "XMLHttpRequest" }); errdefer page.releaseArena(arena); return page._factory.xhrEventTarget(arena, XMLHttpRequest{ ._page = page, ._arena = arena, ._proto = undefined, ._request_headers = try Headers.init(null, page), }); } pub fn deinit(self: *XMLHttpRequest, shutdown: bool, session: *Session) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); } else { transfer.abort(error.Abort); } self._transfer = null; } if (self._on_ready_state_change) |func| { func.release(); } { const proto = self._proto; if (proto._on_abort) |func| { func.release(); } if (proto._on_error) |func| { func.release(); } if (proto._on_load) |func| { func.release(); } if (proto._on_load_end) |func| { func.release(); } if (proto._on_load_start) |func| { func.release(); } if (proto._on_progress) |func| { func.release(); } if (proto._on_timeout) |func| { func.release(); } } session.releaseArena(self._arena); } fn asEventTarget(self: *XMLHttpRequest) *EventTarget { return self._proto._proto; } pub fn getOnReadyStateChange(self: *const XMLHttpRequest) ?js.Function.Temp { return self._on_ready_state_change; } pub fn setOnReadyStateChange(self: *XMLHttpRequest, cb_: ?js.Function) !void { if (cb_) |cb| { self._on_ready_state_change = try cb.tempWithThis(self); } else { self._on_ready_state_change = null; } } pub fn getWithCredentials(self: *const XMLHttpRequest) bool { return self._with_credentials; } pub fn setWithCredentials(self: *XMLHttpRequest, value: bool) !void { if (self._ready_state != .unsent and self._ready_state != .opened) { return error.InvalidStateError; } self._with_credentials = value; } // TODO: this takes an optional 3 more parameters // TODO: url should be a union, as it can be multiple things pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void { // Abort any in-progress request if (self._transfer) |transfer| { transfer.abort(error.Abort); self._transfer = null; } // Reset internal state self._response = null; self._response_data.clearRetainingCapacity(); self._response_status = 0; self._response_len = 0; self._response_url = ""; self._response_mime = null; self._response_headers.clearRetainingCapacity(); self._request_body = null; const page = self._page; self._method = try parseMethod(method_); self._url = try URL.resolve(self._arena, page.base(), url, .{ .always_dupe = true, .encode = true }); try self.stateChanged(.opened, page); } pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const u8, page: *Page) !void { if (self._ready_state != .opened) { return error.InvalidStateError; } return self._request_headers.append(name, value, page); } pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void { if (comptime IS_DEBUG) { log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url }); } if (self._ready_state != .opened) { return error.InvalidStateError; } if (body_) |b| { if (self._method != .GET and self._method != .HEAD) { self._request_body = try self._arena.dupe(u8, b); } } const page = self._page; if (std.mem.startsWith(u8, self._url, "blob:")) { return self.handleBlobUrl(page); } const http_client = page._session.browser.http_client; var headers = try http_client.newHeaders(); // Only add cookies for same-origin or when withCredentials is true const cookie_support = self._with_credentials or try page.isSameOrigin(self._url); try self._request_headers.populateHttpHeader(page.call_arena, &headers); if (cookie_support) { try page.headersForRequest(self._arena, self._url, &headers); } try http_client.request(.{ .ctx = self, .url = self._url, .method = self._method, .headers = headers, .frame_id = page._frame_id, .body = self._request_body, .cookie_jar = if (cookie_support) &page._session.cookie_jar else null, .resource_type = .xhr, .notification = page._session.notification, .start_callback = httpStartCallback, .header_callback = httpHeaderDoneCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, .error_callback = httpErrorCallback, .shutdown_callback = httpShutdownCallback, }); page.js.strongRef(self); } fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void { const blob = page.lookupBlobUrl(self._url) orelse { self.handleError(error.BlobNotFound); return; }; self._response_status = 200; self._response_url = self._url; try self._response_data.appendSlice(self._arena, blob._slice); self._response_len = blob._slice.len; try self.stateChanged(.headers_received, page); try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, page); try self.stateChanged(.loading, page); try self._proto.dispatch(.progress, .{ .total = self._response_len orelse 0, .loaded = self._response_data.items.len, }, page); try self.stateChanged(.done, page); const loaded = self._response_data.items.len; try self._proto.dispatch(.load, .{ .total = loaded, .loaded = loaded, }, page); try self._proto.dispatch(.load_end, .{ .total = loaded, .loaded = loaded, }, page); } pub fn getReadyState(self: *const XMLHttpRequest) u32 { return @intFromEnum(self._ready_state); } pub fn getResponseHeader(self: *const XMLHttpRequest, name: []const u8) ?[]const u8 { for (self._response_headers.items) |entry| { if (entry.len <= name.len) { continue; } if (std.ascii.eqlIgnoreCase(name, entry[0..name.len]) == false) { continue; } if (entry[name.len] != ':') { continue; } return std.mem.trimLeft(u8, entry[name.len + 1 ..], " "); } return null; } pub fn getAllResponseHeaders(self: *const XMLHttpRequest, page: *Page) ![]const u8 { if (self._ready_state != .done) { // MDN says this should return null, but it seems to return an empty string // in every browser. Specs are too hard for a dumbo like me to understand. return ""; } var buf = std.Io.Writer.Allocating.init(page.call_arena); for (self._response_headers.items) |entry| { try buf.writer.writeAll(entry); try buf.writer.writeAll("\r\n"); } return buf.written(); } pub fn getResponseType(self: *const XMLHttpRequest) []const u8 { if (self._ready_state != .done) { return ""; } return @tagName(self._response_type); } pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void { if (std.meta.stringToEnum(ResponseType, value)) |rt| { self._response_type = rt; } } pub fn getResponseText(self: *const XMLHttpRequest) []const u8 { return self._response_data.items; } pub fn getStatus(self: *const XMLHttpRequest) u16 { return self._response_status; } pub fn getStatusText(self: *const XMLHttpRequest) []const u8 { return std.http.Status.phrase(@enumFromInt(self._response_status)) orelse ""; } pub fn getResponseURL(self: *XMLHttpRequest) []const u8 { return self._response_url; } pub fn getResponse(self: *XMLHttpRequest, page: *Page) !?Response { if (self._ready_state != .done) { return null; } if (self._response) |res| { // was already loaded return res; } const data = self._response_data.items; const res: Response = switch (self._response_type) { .text => .{ .text = data }, .json => blk: { const value = try page.js.local.?.parseJSON(data); break :blk .{ .json = try value.persist() }; }, .document => blk: { const document = try page._factory.node(Node.Document{ ._proto = undefined, ._type = .generic }); try page.parseHtmlAsChildren(document.asNode(), data); break :blk .{ .document = document }; }, .arraybuffer => .{ .arraybuffer = .{ .values = data } }, }; self._response = res; return res; } pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document { const res = (try self.getResponse(page)) orelse return null; return switch (res) { .document => |doc| doc, else => null, }; } fn httpStartCallback(transfer: *HttpClient.Transfer) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); if (comptime IS_DEBUG) { log.debug(.http, "request start", .{ .method = self._method, .url = self._url, .source = "xhr" }); } self._transfer = transfer; } fn httpHeaderCallback(transfer: *HttpClient.Transfer, header: net_http.Header) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value }); try self._response_headers.append(self._arena, joined); } fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const header = &transfer.response_header.?; if (comptime IS_DEBUG) { log.debug(.http, "request header", .{ .source = "xhr", .url = self._url, .status = header.status, }); } if (header.contentType()) |ct| { self._response_mime = Mime.parse(ct) catch |e| { log.info(.http, "invalid content type", .{ .content_Type = ct, .err = e, .url = self._url, }); return false; }; } var it = transfer.responseHeaderIterator(); while (it.next()) |hdr| { const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value }); try self._response_headers.append(self._arena, joined); } self._response_status = header.status; if (transfer.getContentLength()) |cl| { self._response_len = cl; try self._response_data.ensureTotalCapacity(self._arena, cl); } self._response_url = try self._arena.dupeZ(u8, std.mem.span(header.url)); const page = self._page; var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); try self.stateChanged(.headers_received, page); try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, page); try self.stateChanged(.loading, page); return true; } fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); try self._response_data.appendSlice(self._arena, data); const page = self._page; try self._proto.dispatch(.progress, .{ .total = self._response_len orelse 0, .loaded = self._response_data.items.len, }, page); } fn httpDoneCallback(ctx: *anyopaque) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); log.info(.http, "request complete", .{ .source = "xhr", .url = self._url, .status = self._response_status, .len = self._response_data.items.len, }); // Not that the request is done, the http/client will free the transfer // object. It isn't safe to keep it around. self._transfer = null; const page = self._page; try self.stateChanged(.done, page); const loaded = self._response_data.items.len; try self._proto.dispatch(.load, .{ .total = loaded, .loaded = loaded, }, page); try self._proto.dispatch(.load_end, .{ .total = loaded, .loaded = loaded, }, page); page.js.weakRef(self); } fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); // http client will close it after an error, it isn't safe to keep around self._transfer = null; self.handleError(err); self._page.js.weakRef(self); } fn httpShutdownCallback(ctx: *anyopaque) void { const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); self._transfer = null; } pub fn abort(self: *XMLHttpRequest) void { self.handleError(error.Abort); if (self._transfer) |transfer| { transfer.abort(error.Abort); self._transfer = null; } self._page.js.weakRef(self); } fn handleError(self: *XMLHttpRequest, err: anyerror) void { self._handleError(err) catch |inner| { log.err(.http, "handle error error", .{ .original = err, .err = inner, }); }; } fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { const is_abort = err == error.Abort; const new_state: ReadyState = if (is_abort) .unsent else .done; if (new_state != self._ready_state) { const page = self._page; try self.stateChanged(new_state, page); if (is_abort) { try self._proto.dispatch(.abort, null, page); } try self._proto.dispatch(.err, null, page); try self._proto.dispatch(.load_end, null, page); } const level: log.Level = if (err == error.Abort) .debug else .err; log.log(.http, level, "error", .{ .url = self._url, .err = err, .source = "xhr.handleError", }); } fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void { if (state == self._ready_state) { return; } self._ready_state = state; const target = self.asEventTarget(); if (page._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) { const event = try Event.initTrusted(.wrap("readystatechange"), .{}, page); try page._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" }); } } fn parseMethod(method: []const u8) !net_http.Method { if (std.ascii.eqlIgnoreCase(method, "get")) { return .GET; } if (std.ascii.eqlIgnoreCase(method, "post")) { return .POST; } if (std.ascii.eqlIgnoreCase(method, "delete")) { return .DELETE; } if (std.ascii.eqlIgnoreCase(method, "put")) { return .PUT; } if (std.ascii.eqlIgnoreCase(method, "propfind")) { return .PROPFIND; } return error.InvalidMethod; } pub const JsApi = struct { pub const bridge = js.Bridge(XMLHttpRequest); pub const Meta = struct { pub const name = "XMLHttpRequest"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const weak = true; pub const finalizer = bridge.finalizer(XMLHttpRequest.deinit); }; pub const constructor = bridge.constructor(XMLHttpRequest.init, .{}); pub const UNSENT = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.unsent), .{ .template = true }); pub const OPENED = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.opened), .{ .template = true }); pub const HEADERS_RECEIVED = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.headers_received), .{ .template = true }); pub const LOADING = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.loading), .{ .template = true }); pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.done), .{ .template = true }); pub const onreadystatechange = bridge.accessor(XMLHttpRequest.getOnReadyStateChange, XMLHttpRequest.setOnReadyStateChange, .{}); pub const withCredentials = bridge.accessor(XMLHttpRequest.getWithCredentials, XMLHttpRequest.setWithCredentials, .{ .dom_exception = true }); pub const open = bridge.function(XMLHttpRequest.open, .{}); pub const send = bridge.function(XMLHttpRequest.send, .{ .dom_exception = true }); pub const responseType = bridge.accessor(XMLHttpRequest.getResponseType, XMLHttpRequest.setResponseType, .{}); pub const status = bridge.accessor(XMLHttpRequest.getStatus, null, .{}); pub const statusText = bridge.accessor(XMLHttpRequest.getStatusText, null, .{}); pub const readyState = bridge.accessor(XMLHttpRequest.getReadyState, null, .{}); pub const response = bridge.accessor(XMLHttpRequest.getResponse, null, .{}); pub const responseText = bridge.accessor(XMLHttpRequest.getResponseText, null, .{}); pub const responseXML = bridge.accessor(XMLHttpRequest.getResponseXML, null, .{}); pub const responseURL = bridge.accessor(XMLHttpRequest.getResponseURL, null, .{}); pub const setRequestHeader = bridge.function(XMLHttpRequest.setRequestHeader, .{ .dom_exception = true }); pub const getResponseHeader = bridge.function(XMLHttpRequest.getResponseHeader, .{}); pub const getAllResponseHeaders = bridge.function(XMLHttpRequest.getAllResponseHeaders, .{}); pub const abort = bridge.function(XMLHttpRequest.abort, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: XHR" { try testing.htmlRunner("net/xhr.html", .{}); } ================================================ FILE: src/browser/webapi/net/XMLHttpRequestEventTarget.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const EventTarget = @import("../EventTarget.zig"); const ProgressEvent = @import("../event/ProgressEvent.zig"); const XMLHttpRequestEventTarget = @This(); _type: Type, _proto: *EventTarget, _on_abort: ?js.Function.Temp = null, _on_error: ?js.Function.Temp = null, _on_load: ?js.Function.Temp = null, _on_load_end: ?js.Function.Temp = null, _on_load_start: ?js.Function.Temp = null, _on_progress: ?js.Function.Temp = null, _on_timeout: ?js.Function.Temp = null, pub const Type = union(enum) { request: *@import("XMLHttpRequest.zig"), // TODO: xml_http_request_upload }; pub fn asEventTarget(self: *XMLHttpRequestEventTarget) *EventTarget { return self._proto; } pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchType, progress_: ?Progress, page: *Page) !void { const field, const typ = comptime blk: { break :blk switch (event_type) { .abort => .{ "_on_abort", "abort" }, .err => .{ "_on_error", "error" }, .load => .{ "_on_load", "load" }, .load_end => .{ "_on_load_end", "loadend" }, .load_start => .{ "_on_load_start", "loadstart" }, .progress => .{ "_on_progress", "progress" }, .timeout => .{ "_on_timeout", "timeout" }, }; }; const progress = progress_ orelse Progress{}; const event = (try ProgressEvent.initTrusted( comptime .wrap(typ), .{ .total = progress.total, .loaded = progress.loaded }, page, )).asEvent(); return page._event_manager.dispatchDirect( self.asEventTarget(), event, @field(self, field), .{ .context = "XHR " ++ typ }, ); } pub fn getOnAbort(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_abort; } pub fn setOnAbort(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { self._on_abort = cb; } pub fn getOnError(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_error; } pub fn setOnError(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { self._on_error = cb; } pub fn getOnLoad(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_load; } pub fn setOnLoad(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { self._on_load = cb; } pub fn getOnLoadEnd(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_load_end; } pub fn setOnLoadEnd(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { self._on_load_end = cb; } pub fn getOnLoadStart(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_load_start; } pub fn setOnLoadStart(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { self._on_load_start = cb; } pub fn getOnProgress(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_progress; } pub fn setOnProgress(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { self._on_progress = cb; } pub fn getOnTimeout(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_timeout; } pub fn setOnTimeout(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void { if (cb_) |cb| { self._on_timeout = try cb.tempWithThis(self); } else { self._on_timeout = null; } } const DispatchType = enum { abort, err, load, load_end, load_start, progress, timeout, }; const Progress = struct { loaded: usize = 0, total: usize = 0, }; pub const JsApi = struct { pub const bridge = js.Bridge(XMLHttpRequestEventTarget); pub const Meta = struct { pub const name = "XMLHttpRequestEventTarget"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const onloadstart = bridge.accessor(XMLHttpRequestEventTarget.getOnLoadStart, XMLHttpRequestEventTarget.setOnLoadStart, .{}); pub const onprogress = bridge.accessor(XMLHttpRequestEventTarget.getOnProgress, XMLHttpRequestEventTarget.setOnProgress, .{}); pub const onabort = bridge.accessor(XMLHttpRequestEventTarget.getOnAbort, XMLHttpRequestEventTarget.setOnAbort, .{}); pub const onerror = bridge.accessor(XMLHttpRequestEventTarget.getOnError, XMLHttpRequestEventTarget.setOnError, .{}); pub const onload = bridge.accessor(XMLHttpRequestEventTarget.getOnLoad, XMLHttpRequestEventTarget.setOnLoad, .{}); pub const ontimeout = bridge.accessor(XMLHttpRequestEventTarget.getOnTimeout, XMLHttpRequestEventTarget.setOnTimeout, .{}); pub const onloadend = bridge.accessor(XMLHttpRequestEventTarget.getOnLoadEnd, XMLHttpRequestEventTarget.setOnLoadEnd, .{}); }; ================================================ FILE: src/browser/webapi/selector/List.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Page = @import("../../Page.zig"); const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const Part = @import("Selector.zig").Part; const Selector = @import("Selector.zig"); const TreeWalker = @import("../TreeWalker.zig").Full; const GenericIterator = @import("../collections/iterator.zig").Entry; const Allocator = std.mem.Allocator; const List = @This(); _nodes: []const *Node, _arena: Allocator, // For the [somewhat common] case where we just have an #id selector // we can avoid allocating a slice and just use this. _single_node: [1]*Node = undefined, pub const EntryIterator = GenericIterator(Iterator, null); pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); pub fn deinit(self: *const List, session: *Session) void { session.releaseArena(self._arena); } pub fn collect( allocator: std.mem.Allocator, root: *Node, selector: Selector.Selector, nodes: *std.AutoArrayHashMapUnmanaged(*Node, void), page: *Page, ) !void { if (optimizeSelector(root, &selector, page)) |result| { var tw = TreeWalker.init(result.root, .{}); if (result.exclude_root) { _ = tw.next(); } while (tw.next()) |node| { if (matches(node, result.selector, root, page)) { try nodes.put(allocator, node, {}); } } } } // used internally to find the first match pub fn initOne(root: *Node, selector: Selector.Selector, page: *Page) ?*Node { const result = optimizeSelector(root, &selector, page) orelse return null; var tw = TreeWalker.init(result.root, .{}); if (result.exclude_root) { _ = tw.next(); } while (tw.next()) |node| { if (matches(node, result.selector, root, page)) { return node; } } return null; } const OptimizeResult = struct { root: *Node, exclude_root: bool, selector: Selector.Selector, }; fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page) ?OptimizeResult { const anchor = findIdSelector(selector) orelse return .{ .root = root, .selector = selector.*, // Always exclude root - querySelector only returns descendants .exclude_root = true, }; // If we have a selector with an #id, we can make a pretty easy and // powerful optimization. We can use the node for that id as the new // root, and only match the selectors after it. However, we'll need to // make sure that node matches the selectors before it (the prefix). const id = anchor.id; const segment_index = anchor.segment_index; // Look up the element by ID (O(1) hash map lookup) const id_element = page.getElementByIdFromNode(root, id) orelse return null; const id_node = id_element.asNode(); if (!root.contains(id_node)) { return null; } // If the ID is in the first compound if (segment_index == null) { // Check if there are any segments after the ID if (selector.segments.len == 0) { // Just '#id', return the node itself return .{ .root = id_node, .selector = .{ .first = selector.first, .segments = selector.segments, }, .exclude_root = false, }; } // Check the combinator of the first segment const first_combinator = selector.segments[0].combinator; if (first_combinator == .next_sibling or first_combinator == .subsequent_sibling) { // Cannot optimize: matches are siblings, not descendants of the ID node // Fall back to searching the entire tree return .{ .root = root, .selector = selector.*, .exclude_root = true, }; } // Safe to optimize for descendant/child combinators return .{ .root = id_node, .selector = .{ .first = selector.first, .segments = selector.segments, }, .exclude_root = true, }; } // ID is in one of the segments const seg_idx = segment_index.?; // Check if there are segments after the ID if (seg_idx + 1 < selector.segments.len) { // Check the combinator of the segment after the ID const next_combinator = selector.segments[seg_idx + 1].combinator; if (next_combinator == .next_sibling or next_combinator == .subsequent_sibling) { // Cannot optimize: matches are siblings, not descendants return .{ .root = root, .selector = selector.*, .exclude_root = true, }; } } // If there's a prefix selector, we need to verify that the id_node's // ancestors match it. We construct a selector up to and including the ID segment. const prefix_selector = Selector.Selector{ .first = selector.first, .segments = selector.segments[0 .. seg_idx + 1], }; if (!matches(id_node, prefix_selector, id_node, page)) { return null; } // Return a selector starting from the segments after the ID return .{ .root = id_node, .selector = .{ .first = selector.segments[seg_idx].compound, .segments = selector.segments[seg_idx + 1 ..], }, .exclude_root = false, }; } pub fn getLength(self: *const List) usize { return self._nodes.len; } pub fn keys(self: *List, page: *Page) !*KeyIterator { return .init(.{ .list = self }, page); } pub fn values(self: *List, page: *Page) !*ValueIterator { return .init(.{ .list = self }, page); } pub fn entries(self: *List, page: *Page) !*EntryIterator { return .init(.{ .list = self }, page); } pub fn getAtIndex(self: *const List, index: usize) !?*Node { if (index >= self._nodes.len) { return null; } return self._nodes[index]; } const NodeList = @import("../collections/NodeList.zig"); pub fn runtimeGenericWrap(self: *List, _: *const Page) !*NodeList { const nl = try self._arena.create(NodeList); nl.* = .{ ._data = .{ .selector_list = self }, }; return nl; } const IdAnchor = struct { id: []const u8, segment_index: ?usize, // null if ID is in first compound }; // Rightmost (last) is best because it minimizes the subtree we need to search fn findIdSelector(selector: *const Selector.Selector) ?IdAnchor { // Check segments from right to left var i = selector.segments.len; while (i > 0) { i -= 1; const compound = selector.segments[i].compound.parts; if (compound.len != 1) { continue; } const part = compound[0]; if (part == .id) { return .{ .id = part.id, .segment_index = i }; } } // Check the first compound if (selector.first.parts.len == 1) { const part = selector.first.parts[0]; if (part == .id) { return .{ .id = part.id, .segment_index = null }; } } return null; } pub fn matches(node: *Node, selector: Selector.Selector, scope: *Node, page: *Page) bool { const el = node.is(Node.Element) orelse return false; if (selector.segments.len == 0) { return matchesCompound(el, selector.first, scope, page); } const last_segment = selector.segments[selector.segments.len - 1]; if (!matchesCompound(el, last_segment.compound, scope, page)) { return false; } return matchSegments(node, selector, selector.segments.len - 1, null, scope, page); } // Match segments backward, with support for backtracking on subsequent_sibling fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, root: ?*Node, scope: *Node, page: *Page) bool { const segment = selector.segments[segment_index]; const target_compound = if (segment_index == 0) selector.first else selector.segments[segment_index - 1].compound; const matched: ?*Node = switch (segment.combinator) { .descendant => matchDescendant(node, target_compound, root, scope, page), .child => matchChild(node, target_compound, root, scope, page), .next_sibling => matchNextSibling(node, target_compound, scope, page), .subsequent_sibling => { // For subsequent_sibling, try all matching siblings with backtracking var sibling = node.previousSibling(); while (sibling) |s| { const sibling_el = s.is(Node.Element) orelse { sibling = s.previousSibling(); continue; }; if (matchesCompound(sibling_el, target_compound, scope, page)) { // If we're at the first segment, we found a match if (segment_index == 0) { return true; } // Try to match remaining segments from this sibling if (matchSegments(s, selector, segment_index - 1, root, scope, page)) { return true; } // This sibling didn't work, try the next one } sibling = s.previousSibling(); } return false; }, }; // For non-subsequent_sibling combinators, matched is either the node or null if (segment.combinator != .subsequent_sibling) { const current = matched orelse return false; if (segment_index == 0) { return true; } return matchSegments(current, selector, segment_index - 1, root, scope, page); } // subsequent_sibling already handled its recursion above return false; } // Find an ancestor that matches the compound (any distance up the tree) fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node, scope: *Node, page: *Page) ?*Node { var current = node._parent; while (current) |ancestor| { if (ancestor.is(Node.Element)) |ancestor_el| { if (matchesCompound(ancestor_el, compound, scope, page)) { return ancestor; } } // Stop if we've reached the boundary if (root) |boundary| { if (ancestor == boundary) { return null; } } current = ancestor._parent; } return null; } // Find the direct parent if it matches the compound fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node, scope: *Node, page: *Page) ?*Node { const parent = node._parent orelse return null; // Don't match beyond the root boundary // If there's a boundary, check if parent is outside (an ancestor of) the boundary if (root) |boundary| { if (!boundary.contains(parent)) { return null; } } const parent_el = parent.is(Node.Element) orelse return null; if (matchesCompound(parent_el, compound, scope, page)) { return parent; } return null; } // Find the immediately preceding sibling if it matches the compound fn matchNextSibling(node: *Node, compound: Selector.Compound, scope: *Node, page: *Page) ?*Node { var sibling = node.previousSibling(); // For next_sibling (+), we need the immediately preceding element sibling while (sibling) |s| { const sibling_el = s.is(Node.Element) orelse { // Skip non-element nodes sibling = s.previousSibling(); continue; }; // Found an element - check if it matches if (matchesCompound(sibling_el, compound, scope, page)) { return s; } // we found an element, it wasn't a match, we're done return null; } return null; } // Find any preceding sibling that matches the compound fn matchSubsequentSibling(node: *Node, compound: Selector.Compound, scope: *Node, page: *Page) ?*Node { var sibling = node.previousSibling(); // For subsequent_sibling (~), check all preceding element siblings while (sibling) |s| { const sibling_el = s.is(Node.Element) orelse { // Skip non-element nodes sibling = s.previousSibling(); continue; }; if (matchesCompound(sibling_el, compound, scope, page)) { return s; } sibling = s.previousSibling(); } return null; } fn matchesCompound(el: *Node.Element, compound: Selector.Compound, scope: *Node, page: *Page) bool { // For compound selectors, ALL parts must match for (compound.parts) |part| { if (!matchesPart(el, part, scope, page)) { return false; } } return true; } fn matchesPart(el: *Node.Element, part: Part, scope: *Node, page: *Page) bool { switch (part) { .id => |id| { const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse return false; return std.mem.eql(u8, element_id, id); }, .class => |cls| { const class_attr = el.getAttributeSafe(comptime .wrap("class")) orelse return false; return Selector.classAttributeContains(class_attr, cls); }, .tag => |tag| { // Optimized: compare enum directly return el.getTag() == tag; }, .tag_name => |tag_name| { // Fallback for custom/unknown tags // Both are lowercase, so we can use fast string comparison const element_tag = el.getTagNameLower(); return std.mem.eql(u8, element_tag, tag_name); }, .universal => return true, .pseudo_class => |pseudo| return matchesPseudoClass(el, pseudo, scope, page), .attribute => |attr| return matchesAttribute(el, attr), } } fn matchesAttribute(el: *Node.Element, attr: Selector.Attribute) bool { const value = el.getAttributeSafe(attr.name) orelse { return false; }; switch (attr.matcher) { .presence => return true, .exact => |expected| { return if (attr.case_insensitive) std.ascii.eqlIgnoreCase(value, expected) else std.mem.eql(u8, value, expected); }, .substring => |expected| { return if (attr.case_insensitive) std.ascii.indexOfIgnoreCase(value, expected) != null else std.mem.indexOf(u8, value, expected) != null; }, .starts_with => |expected| { return if (attr.case_insensitive) std.ascii.startsWithIgnoreCase(value, expected) else std.mem.startsWith(u8, value, expected); }, .ends_with => |expected| { return if (attr.case_insensitive) std.ascii.endsWithIgnoreCase(value, expected) else std.mem.endsWith(u8, value, expected); }, .word => |expected| { // Space-separated word match (like class names) var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace); while (it.next()) |word| { const same = if (attr.case_insensitive) std.ascii.eqlIgnoreCase(word, expected) else std.mem.eql(u8, word, expected); if (same) return true; } return false; }, .prefix_dash => |expected| { // Matches value or value- prefix (for language codes like en, en-US) if (attr.case_insensitive) { if (std.ascii.eqlIgnoreCase(value, expected)) return true; if (value.len > expected.len and value[expected.len] == '-') { return std.ascii.eqlIgnoreCase(value[0..expected.len], expected); } } else { if (std.mem.eql(u8, value, expected)) return true; if (value.len > expected.len and value[expected.len] == '-') { return std.mem.eql(u8, value[0..expected.len], expected); } } return false; }, } } fn attributeContainsWord(value: []const u8, word: []const u8) bool { var remaining = value; while (remaining.len > 0) { const trimmed = std.mem.trimLeft(u8, remaining, &std.ascii.whitespace); if (trimmed.len == 0) return false; const end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len; const current_word = trimmed[0..end]; if (std.mem.eql(u8, current_word, word)) { return true; } if (end >= trimmed.len) break; remaining = trimmed[end..]; } return false; } fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *Node, page: *Page) bool { const node = el.asNode(); switch (pseudo) { // State pseudo-classes .modal => return false, .checked => { const input = el.is(Node.Element.Html.Input) orelse return false; return input.getChecked(); }, .disabled => { return el.getAttributeSafe(comptime .wrap("disabled")) != null; }, .enabled => { return el.getAttributeSafe(comptime .wrap("disabled")) == null; }, .indeterminate => { const input = el.is(Node.Element.Html.Input) orelse return false; return switch (input._input_type) { .checkbox => input.getIndeterminate(), else => false, }; }, // Form validation .valid => { if (el.is(Node.Element.Html.Input)) |input| { return switch (input._input_type) { .hidden, .submit, .reset, .button => false, else => !input.getRequired() or input.getValue().len > 0, }; } if (el.is(Node.Element.Html.Select)) |select| { return !select.getRequired() or select.getValue(page).len > 0; } if (el.is(Node.Element.Html.Form) != null or el.is(Node.Element.Html.FieldSet) != null) { return !hasInvalidDescendant(node, page); } return false; }, .invalid => { if (el.is(Node.Element.Html.Input)) |input| { return switch (input._input_type) { .hidden, .submit, .reset, .button => false, else => input.getRequired() and input.getValue().len == 0, }; } if (el.is(Node.Element.Html.Select)) |select| { return select.getRequired() and select.getValue(page).len == 0; } if (el.is(Node.Element.Html.Form) != null or el.is(Node.Element.Html.FieldSet) != null) { return hasInvalidDescendant(node, page); } return false; }, .required => { return el.getAttributeSafe(comptime .wrap("required")) != null; }, .optional => { return el.getAttributeSafe(comptime .wrap("required")) == null; }, .in_range => return false, .out_of_range => return false, .placeholder_shown => return false, .read_only => { return el.getAttributeSafe(comptime .wrap("readonly")) != null; }, .read_write => { return el.getAttributeSafe(comptime .wrap("readonly")) == null; }, .default => return false, // User interaction .hover => return false, .active => return false, .focus => { const active = page.document._active_element orelse return false; return active == el; }, .focus_within => { const active = page.document._active_element orelse return false; return node.contains(active.asNode()); }, .focus_visible => return false, // Link states .link => return false, .visited => return false, .any_link => { if (el.getTag() != .anchor) return false; return el.getAttributeSafe(comptime .wrap("href")) != null; }, .target => { const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse return false; const location = page.document._location orelse return false; const hash = location.getHash(); if (hash.len <= 1) return false; return std.mem.eql(u8, element_id, hash[1..]); }, // Tree structural .root => { const parent = node.parentNode() orelse return false; return parent._type == .document; }, .scope => { // :scope matches the reference element (querySelector root) return node == scope; }, .empty => { return node.firstChild() == null; }, .first_child => return isFirstChild(el), .last_child => return isLastChild(el), .only_child => return isFirstChild(el) and isLastChild(el), .first_of_type => return isFirstOfType(el), .last_of_type => return isLastOfType(el), .only_of_type => return isFirstOfType(el) and isLastOfType(el), .nth_child => |pattern| return matchesNthChild(el, pattern), .nth_last_child => |pattern| return matchesNthLastChild(el, pattern), .nth_of_type => |pattern| return matchesNthOfType(el, pattern), .nth_last_of_type => |pattern| return matchesNthLastOfType(el, pattern), // Custom elements .defined => { const tag_name = el.getTagNameLower(); if (std.mem.indexOfScalar(u8, tag_name, '-') == null) return true; const registry = &page.window._custom_elements; return registry.get(tag_name) != null; }, // Functional .lang => return false, .not => |selectors| { for (selectors) |selector| { if (matches(node, selector, scope, page)) { return false; } } return true; }, .is => |selectors| { for (selectors) |selector| { if (matches(node, selector, scope, page)) { return true; } } return false; }, .where => |selectors| { for (selectors) |selector| { if (matches(node, selector, scope, page)) { return true; } } return false; }, .has => |selectors| { for (selectors) |selector| { var child = node.firstChild(); while (child) |c| { const child_el = c.is(Node.Element) orelse { child = c.nextSibling(); continue; }; if (matches(child_el.asNode(), selector, scope, page)) { return true; } if (matchesHasDescendant(child_el, selector, scope, page)) { return true; } child = c.nextSibling(); } } return false; }, } } fn matchesHasDescendant(el: *Node.Element, selector: Selector.Selector, scope: *Node, page: *Page) bool { var child = el.asNode().firstChild(); while (child) |c| { const child_el = c.is(Node.Element) orelse { child = c.nextSibling(); continue; }; if (matches(child_el.asNode(), selector, scope, page)) { return true; } if (matchesHasDescendant(child_el, selector, scope, page)) { return true; } child = c.nextSibling(); } return false; } fn hasInvalidDescendant(parent: *Node, page: *Page) bool { var child = parent.firstChild(); while (child) |c| { if (c.is(Node.Element)) |child_el| { if (child_el.is(Node.Element.Html.Input)) |input| { const invalid = switch (input._input_type) { .hidden, .submit, .reset, .button => false, else => input.getRequired() and input.getValue().len == 0, }; if (invalid) return true; } else if (child_el.is(Node.Element.Html.Select)) |select| { if (select.getRequired() and select.getValue(page).len == 0) return true; } } if (hasInvalidDescendant(c, page)) return true; child = c.nextSibling(); } return false; } fn isFirstChild(el: *Node.Element) bool { const node = el.asNode(); var sibling = node.previousSibling(); // Check if there are any element siblings before this one while (sibling) |s| { if (s.is(Node.Element)) |_| { return false; } sibling = s.previousSibling(); } return true; } fn isLastChild(el: *Node.Element) bool { const node = el.asNode(); var sibling = node.nextSibling(); // Check if there are any element siblings after this one while (sibling) |s| { if (s.is(Node.Element)) |_| { return false; } sibling = s.nextSibling(); } return true; } fn isFirstOfType(el: *Node.Element) bool { const tag = el.getTag(); const node = el.asNode(); var sibling = node.previousSibling(); // Check if there are any element siblings of the same type before this one while (sibling) |s| { const sibling_el = s.is(Node.Element) orelse { sibling = s.previousSibling(); continue; }; if (sibling_el.getTag() == tag) { return false; } sibling = s.previousSibling(); } return true; } fn isLastOfType(el: *Node.Element) bool { const tag = el.getTag(); const node = el.asNode(); var sibling = node.nextSibling(); // Check if there are any element siblings of the same type after this one while (sibling) |s| { const sibling_el = s.is(Node.Element) orelse { sibling = s.nextSibling(); continue; }; if (sibling_el.getTag() == tag) { return false; } sibling = s.nextSibling(); } return true; } fn matchesNthChild(el: *Node.Element, pattern: Selector.NthPattern) bool { const index = getChildIndex(el) orelse return false; return matchesNthPattern(index, pattern); } fn matchesNthLastChild(el: *Node.Element, pattern: Selector.NthPattern) bool { const index = getChildIndexFromEnd(el) orelse return false; return matchesNthPattern(index, pattern); } fn matchesNthOfType(el: *Node.Element, pattern: Selector.NthPattern) bool { const index = getTypeIndex(el) orelse return false; return matchesNthPattern(index, pattern); } fn matchesNthLastOfType(el: *Node.Element, pattern: Selector.NthPattern) bool { const index = getTypeIndexFromEnd(el) orelse return false; return matchesNthPattern(index, pattern); } fn getChildIndex(el: *Node.Element) ?usize { const node = el.asNode(); var index: usize = 1; var sibling = node.previousSibling(); while (sibling) |s| { if (s.is(Node.Element)) |_| { index += 1; } sibling = s.previousSibling(); } return index; } fn getChildIndexFromEnd(el: *Node.Element) ?usize { const node = el.asNode(); var index: usize = 1; var sibling = node.nextSibling(); while (sibling) |s| { if (s.is(Node.Element)) |_| { index += 1; } sibling = s.nextSibling(); } return index; } fn getTypeIndex(el: *Node.Element) ?usize { const tag = el.getTag(); const node = el.asNode(); var index: usize = 1; var sibling = node.previousSibling(); while (sibling) |s| { const sibling_el = s.is(Node.Element) orelse { sibling = s.previousSibling(); continue; }; if (sibling_el.getTag() == tag) { index += 1; } sibling = s.previousSibling(); } return index; } fn getTypeIndexFromEnd(el: *Node.Element) ?usize { const tag = el.getTag(); const node = el.asNode(); var index: usize = 1; var sibling = node.nextSibling(); while (sibling) |s| { const sibling_el = s.is(Node.Element) orelse { sibling = s.nextSibling(); continue; }; if (sibling_el.getTag() == tag) { index += 1; } sibling = s.nextSibling(); } return index; } fn matchesNthPattern(index: usize, pattern: Selector.NthPattern) bool { const a = pattern.a; const b = pattern.b; // Special case: a=0 means we're matching a specific index if (a == 0) { return @as(i32, @intCast(index)) == b; } // For an+b pattern, we need to find if there's an integer n >= 0 // such that an + b = index // Rearranging: n = (index - b) / a const index_i = @as(i32, @intCast(index)); const diff = index_i - b; // Check if (index - b) is divisible by a if (@rem(diff, a) != 0) { return false; } const n = @divTrunc(diff, a); // n must be non-negative return n >= 0; } const Iterator = struct { index: u32 = 0, list: *List, const Entry = struct { u32, *Node }; pub fn next(self: *Iterator, _: *const Page) ?Entry { const index = self.index; if (index >= self.list._nodes.len) { return null; } self.index = index + 1; return .{ index, self.list._nodes[index] }; } }; ================================================ FILE: src/browser/webapi/selector/Parser.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); const Attribute = @import("../element/Attribute.zig"); const Selector = @import("Selector.zig"); const Part = Selector.Part; const Segment = Selector.Segment; const Combinator = Selector.Combinator; const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; const Parser = @This(); input: []const u8, // need an explicit error set because the function is recursive const ParseError = error{ OutOfMemory, InvalidIDSelector, InvalidClassSelector, InvalidAttributeSelector, InvalidPseudoClass, InvalidNthPattern, UnknownPseudoClass, InvalidTagSelector, InvalidSelector, StringTooLarge, }; // CSS Syntax preprocessing: normalize line endings (CRLF → LF, CR → LF) // https://drafts.csswg.org/css-syntax/#input-preprocessing fn preprocessInput(arena: Allocator, input: []const u8) ![]const u8 { var i = std.mem.indexOfScalar(u8, input, '\r') orelse return input; var result = try std.ArrayList(u8).initCapacity(arena, input.len); result.appendSliceAssumeCapacity(input[0..i]); while (i < input.len) { const c = input[i]; if (c == '\r') { result.appendAssumeCapacity('\n'); i += 1; if (i < input.len and input[i] == '\n') { i += 1; } } else { result.appendAssumeCapacity(c); i += 1; } } return result.items; } pub fn parseList(arena: Allocator, input: []const u8, page: *Page) ParseError![]const Selector.Selector { // Preprocess input to normalize line endings const preprocessed = try preprocessInput(arena, input); var selectors: std.ArrayList(Selector.Selector) = .empty; var remaining = preprocessed; while (true) { const trimmed = std.mem.trimLeft(u8, remaining, &std.ascii.whitespace); if (trimmed.len == 0) break; var comma_pos: usize = trimmed.len; var depth: usize = 0; var in_quote: u8 = 0; // 0 = not in quotes, '"' or '\'' = in that quote type var i: usize = 0; while (i < trimmed.len) { const c = trimmed[i]; if (in_quote != 0) { // Inside a quoted string if (c == '\\') { // Skip escape sequence inside quotes i += 1; if (i < trimmed.len) i += 1; } else if (c == in_quote) { // Closing quote in_quote = 0; i += 1; } else { i += 1; } continue; } switch (c) { '\\' => { // Skip escape sequence (backslash + next character) i += 1; if (i < trimmed.len) i += 1; }, '"', '\'' => { in_quote = c; i += 1; }, '(' => { depth += 1; i += 1; }, ')' => { if (depth > 0) depth -= 1; i += 1; }, ',' => { if (depth == 0) { comma_pos = i; break; } i += 1; }, else => { i += 1; }, } } const selector_input = std.mem.trimRight(u8, trimmed[0..comma_pos], &std.ascii.whitespace); if (selector_input.len > 0) { const selector = try parse(arena, selector_input, page); try selectors.append(arena, selector); } if (comma_pos >= trimmed.len) break; remaining = trimmed[comma_pos + 1 ..]; } if (selectors.items.len == 0) { return error.InvalidSelector; } return selectors.items; } pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Selector.Selector { var parser = Parser{ .input = input }; var segments: std.ArrayList(Segment) = .empty; var current_compound: std.ArrayList(Part) = .empty; // Parse the first compound (no combinator before it) while (parser.skipSpaces()) { if (parser.peek() == 0) break; const part = try parser.parsePart(arena, page); try current_compound.append(arena, part); // Check what comes after this part const start_pos = parser.input; const has_whitespace = parser.skipSpacesConsumed(); const next = parser.peek(); if (next == 0) { // End of input break; } if (next == '>' or next == '+' or next == '~') { // Explicit combinator break; } if (has_whitespace and isStartOfPart(next)) { // Whitespace followed by another selector part = descendant combinator // Restore position before the whitespace so the segment loop can handle it parser.input = start_pos; break; } // If we have a non-whitespace character that could start a part, // it's part of this compound (like "div.class" or "div#id") if (!has_whitespace and isStartOfPart(next)) { // Continue parsing this compound continue; } // Otherwise, end of compound break; } if (current_compound.items.len == 0) { return error.InvalidSelector; } const first_compound = current_compound.items; current_compound = .empty; // Parse remaining segments with combinators while (parser.skipSpaces()) { const next = parser.peek(); if (next == 0) break; // Parse combinator const combinator: Combinator = switch (next) { '>' => blk: { parser.input = parser.input[1..]; break :blk .child; }, '+' => blk: { parser.input = parser.input[1..]; break :blk .next_sibling; }, '~' => blk: { parser.input = parser.input[1..]; break :blk .subsequent_sibling; }, else => .descendant, // whitespace = descendant combinator }; // Parse the compound that follows the combinator _ = parser.skipSpaces(); if (parser.peek() == 0) { return error.InvalidSelector; // Combinator with nothing after it } while (parser.skipSpaces()) { if (parser.peek() == 0) break; const part = try parser.parsePart(arena, page); try current_compound.append(arena, part); // Check what comes after this part const seg_start_pos = parser.input; const seg_has_whitespace = parser.skipSpacesConsumed(); const peek_next = parser.peek(); if (peek_next == 0) { // End of input break; } if (peek_next == '>' or peek_next == '+' or peek_next == '~') { // Next combinator found break; } if (seg_has_whitespace and isStartOfPart(peek_next)) { // Whitespace followed by another part = new segment // Restore position before whitespace parser.input = seg_start_pos; break; } // If no whitespace and it's a start of part, continue compound if (!seg_has_whitespace and isStartOfPart(peek_next)) { continue; } // Otherwise, end of compound break; } if (current_compound.items.len == 0) { return error.InvalidSelector; } try segments.append(arena, .{ .combinator = combinator, .compound = .{ .parts = current_compound.items }, }); current_compound = .empty; } return .{ .first = .{ .parts = first_compound }, .segments = segments.items, }; } fn parsePart(self: *Parser, arena: Allocator, page: *Page) !Part { return switch (self.peek()) { '#' => .{ .id = try self.id(arena) }, '.' => .{ .class = try self.class(arena) }, '*' => blk: { self.input = self.input[1..]; break :blk .universal; }, '[' => .{ .attribute = try self.attribute(arena, page) }, ':' => .{ .pseudo_class = try self.pseudoClass(arena, page) }, 'a'...'z', 'A'...'Z', '_', '\\', 0x80...0xFF => blk: { // Use parseIdentifier for full escape support const tag_name = try self.parseIdentifier(arena, error.InvalidTagSelector); if (tag_name.len > 256) { return error.InvalidTagSelector; } // Try to match as a known tag enum for optimization const lower = std.ascii.lowerString(&page.buf, tag_name); if (Node.Element.Tag.parseForMatch(lower)) |known_tag| { break :blk .{ .tag = known_tag }; } // Store lowercased for fast comparison const lower_tag = try arena.dupe(u8, lower); break :blk .{ .tag_name = lower_tag }; }, else => error.InvalidSelector, }; } fn isStartOfPart(c: u8) bool { return switch (c) { '#', '.', '*', '[', ':', 'a'...'z', 'A'...'Z', '_' => true, else => false, }; } // Returns true if there's more input after trimming whitespace fn skipSpaces(self: *Parser) bool { const trimmed = std.mem.trimLeft(u8, self.input, &std.ascii.whitespace); self.input = trimmed; return trimmed.len > 0; } // Returns true if whitespace was actually removed fn skipSpacesConsumed(self: *Parser) bool { const original_len = self.input.len; const trimmed = std.mem.trimLeft(u8, self.input, &std.ascii.whitespace); self.input = trimmed; return trimmed.len < original_len; } fn peek(self: *const Parser) u8 { const input = self.input; if (input.len == 0) { return 0; } return input[0]; } fn consumeUntilCommaOrParen(self: *Parser) []const u8 { const input = self.input; var depth: usize = 0; var i: usize = 0; while (i < input.len) : (i += 1) { const c = input[i]; switch (c) { '(' => depth += 1, ')' => { if (depth == 0) break; depth -= 1; }, ',' => { if (depth == 0) break; }, else => {}, } } const result = input[0..i]; self.input = input[i..]; return result; } fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoClass { if (comptime IS_DEBUG) { // Should have been verified by caller std.debug.assert(self.peek() == ':'); } self.input = self.input[1..]; // Parse the pseudo-class name const start = self.input; var i: usize = 0; while (i < start.len) : (i += 1) { const c = start[i]; if (!std.ascii.isAlphanumeric(c) and c != '-') { break; } } if (i == 0) { return error.InvalidPseudoClass; } const name = start[0..i]; self.input = start[i..]; const next = self.peek(); // Check for functional pseudo-classes like :nth-child(2n+1) or :not(...) if (next == '(') { self.input = self.input[1..]; // Skip '(' if (std.mem.eql(u8, name, "nth-child")) { const pattern = try self.parseNthPattern(); if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; return .{ .nth_child = pattern }; } if (std.mem.eql(u8, name, "nth-last-child")) { const pattern = try self.parseNthPattern(); if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; return .{ .nth_last_child = pattern }; } if (std.mem.eql(u8, name, "nth-of-type")) { const pattern = try self.parseNthPattern(); if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; return .{ .nth_of_type = pattern }; } if (std.mem.eql(u8, name, "nth-last-of-type")) { const pattern = try self.parseNthPattern(); if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; return .{ .nth_last_of_type = pattern }; } if (std.mem.eql(u8, name, "not")) { // CSS Level 4: :not() can contain a full selector list (comma-separated selectors) // e.g., :not(div, .class, #id > span) var selectors: std.ArrayList(Selector.Selector) = .empty; _ = self.skipSpaces(); // Parse comma-separated selectors while (true) { if (self.peek() == ')') break; if (self.peek() == 0) return error.InvalidPseudoClass; // Parse a full selector (with potential combinators and compounds) const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); try selectors.append(arena, selector); _ = self.skipSpaces(); if (self.peek() == ',') { self.input = self.input[1..]; // Skip comma _ = self.skipSpaces(); continue; } break; } if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; // Skip ')' if (selectors.items.len == 0) return error.InvalidPseudoClass; return .{ .not = selectors.items }; } if (std.mem.eql(u8, name, "is")) { var selectors: std.ArrayList(Selector.Selector) = .empty; _ = self.skipSpaces(); while (true) { if (self.peek() == ')') break; if (self.peek() == 0) return error.InvalidPseudoClass; const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); try selectors.append(arena, selector); _ = self.skipSpaces(); if (self.peek() == ',') { self.input = self.input[1..]; _ = self.skipSpaces(); continue; } break; } if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; // Empty :is() is valid per spec - matches nothing return .{ .is = selectors.items }; } if (std.mem.eql(u8, name, "where")) { var selectors: std.ArrayList(Selector.Selector) = .empty; _ = self.skipSpaces(); while (true) { if (self.peek() == ')') break; if (self.peek() == 0) return error.InvalidPseudoClass; const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); try selectors.append(arena, selector); _ = self.skipSpaces(); if (self.peek() == ',') { self.input = self.input[1..]; _ = self.skipSpaces(); continue; } break; } if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; // Empty :where() is valid per spec - matches nothing return .{ .where = selectors.items }; } if (std.mem.eql(u8, name, "has")) { var selectors: std.ArrayList(Selector.Selector) = .empty; _ = self.skipSpaces(); while (true) { if (self.peek() == ')') break; if (self.peek() == 0) return error.InvalidPseudoClass; const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); try selectors.append(arena, selector); _ = self.skipSpaces(); if (self.peek() == ',') { self.input = self.input[1..]; _ = self.skipSpaces(); continue; } break; } if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; if (selectors.items.len == 0) return error.InvalidPseudoClass; return .{ .has = selectors.items }; } if (std.mem.eql(u8, name, "lang")) { _ = self.skipSpaces(); const lang_start = self.input; var lang_i: usize = 0; while (lang_i < lang_start.len and lang_start[lang_i] != ')') : (lang_i += 1) {} if (lang_i == 0 or self.peek() == 0) return error.InvalidPseudoClass; const lang = try arena.dupe(u8, std.mem.trim(u8, lang_start[0..lang_i], &std.ascii.whitespace)); self.input = lang_start[lang_i..]; if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; return .{ .lang = lang }; } return error.UnknownPseudoClass; } switch (name.len) { 4 => { if (fastEql(name, "root")) return .root; if (fastEql(name, "link")) return .link; }, 5 => { if (fastEql(name, "modal")) return .modal; if (fastEql(name, "hover")) return .hover; if (fastEql(name, "focus")) return .focus; if (fastEql(name, "scope")) return .scope; if (fastEql(name, "empty")) return .empty; if (fastEql(name, "valid")) return .valid; }, 6 => { if (fastEql(name, "active")) return .active; if (fastEql(name, "target")) return .target; }, 7 => { if (fastEql(name, "checked")) return .checked; if (fastEql(name, "visited")) return .visited; if (fastEql(name, "enabled")) return .enabled; if (fastEql(name, "invalid")) return .invalid; if (fastEql(name, "default")) return .default; if (fastEql(name, "defined")) return .defined; }, 8 => { if (fastEql(name, "disabled")) return .disabled; if (fastEql(name, "required")) return .required; if (fastEql(name, "optional")) return .optional; if (fastEql(name, "any-link")) return .any_link; if (fastEql(name, "in-range")) return .in_range; }, 9 => { if (fastEql(name, "read-only")) return .read_only; }, 10 => { if (fastEql(name, "only-child")) return .only_child; if (fastEql(name, "last-child")) return .last_child; if (fastEql(name, "read-write")) return .read_write; }, 11 => { if (fastEql(name, "first-child")) return .first_child; }, 12 => { if (fastEql(name, "only-of-type")) return .only_of_type; if (fastEql(name, "last-of-type")) return .last_of_type; if (fastEql(name, "focus-within")) return .focus_within; if (fastEql(name, "out-of-range")) return .out_of_range; }, 13 => { if (fastEql(name, "first-of-type")) return .first_of_type; if (fastEql(name, "focus-visible")) return .focus_visible; if (fastEql(name, "indeterminate")) return .indeterminate; }, 17 => { if (fastEql(name, "placeholder-shown")) return .placeholder_shown; }, else => {}, } return error.UnknownPseudoClass; } fn parseNthPattern(self: *Parser) !Selector.NthPattern { _ = self.skipSpaces(); const start = self.input; // Check for special keywords if (std.mem.startsWith(u8, start, "odd")) { self.input = start[3..]; return .{ .a = 2, .b = 1 }; } if (std.mem.startsWith(u8, start, "even")) { self.input = start[4..]; return .{ .a = 2, .b = 0 }; } // Parse An+B notation var a: i32 = 0; var b: i32 = 0; var has_n = false; // Try to parse coefficient 'a' var p = self.peek(); const sign_a: i32 = if (p == '-') blk: { self.input = self.input[1..]; break :blk -1; } else if (p == '+') blk: { self.input = self.input[1..]; break :blk 1; } else 1; p = self.peek(); if (p == 'n' or p == 'N') { // Just 'n' means a=1 a = sign_a; has_n = true; self.input = self.input[1..]; } else { // Parse numeric coefficient var num: i32 = 0; var digit_count: usize = 0; p = self.peek(); while (std.ascii.isDigit(p)) { num = num * 10 + @as(i32, p - '0'); self.input = self.input[1..]; digit_count += 1; p = self.peek(); } if (digit_count > 0) { p = self.peek(); if (p == 'n' or p == 'N') { a = sign_a * num; has_n = true; self.input = self.input[1..]; } else { // Just a number, no 'n', so this is 'b' b = sign_a * num; return .{ .a = 0, .b = b }; } } else if (sign_a != 1) { // We had a sign but no number and no 'n' return error.InvalidNthPattern; } } if (!has_n) { return error.InvalidNthPattern; } // Parse offset 'b' _ = self.skipSpaces(); p = self.peek(); if (p == '+' or p == '-') { const sign_b: i32 = if (p == '-') -1 else 1; self.input = self.input[1..]; _ = self.skipSpaces(); var num: i32 = 0; var digit_count: usize = 0; p = self.peek(); while (std.ascii.isDigit(p)) { num = num * 10 + @as(i32, p - '0'); self.input = self.input[1..]; digit_count += 1; p = self.peek(); } if (digit_count == 0) { return error.InvalidNthPattern; } b = sign_b * num; } return .{ .a = a, .b = b }; } pub fn id(self: *Parser, arena: Allocator) ![]const u8 { if (comptime IS_DEBUG) { // should have been verified by caller std.debug.assert(self.peek() == '#'); } self.input = self.input[1..]; // Skip '#' return self.parseIdentifier(arena, error.InvalidIDSelector); } fn class(self: *Parser, arena: Allocator) ![]const u8 { if (comptime IS_DEBUG) { // should have been verified by caller std.debug.assert(self.peek() == '.'); } self.input = self.input[1..]; // Skip '.' return self.parseIdentifier(arena, error.InvalidClassSelector); } // Parse a CSS identifier (used by id and class selectors) fn parseIdentifier(self: *Parser, arena: Allocator, err: ParseError) ParseError![]const u8 { const input = self.input; if (input.len == 0) { @branchHint(.cold); return err; } var i: usize = 0; const first = input[0]; if (first == '\\' or first == 0) { // First char needs special processing - go straight to slow path } else if (first >= 0x80 or std.ascii.isAlphabetic(first) or first == '_') { // Valid first char i = 1; } else if (first == '-') { // Dash must be followed by dash, letter, underscore, escape, or non-ASCII if (input.len < 2) { @branchHint(.cold); return err; } const second = input[1]; if (second == '-' or second == '\\' or std.ascii.isAlphabetic(second) or second == '_' or second >= 0x80) { i = 1; // First char validated, start scanning from position 1 } else { @branchHint(.cold); return err; } } else { @branchHint(.cold); return err; } // Fast scan remaining characters (no escapes/nulls) while (i < input.len) { const b = input[i]; if (b == '\\' or b == 0) { // Stop at escape or null - need slow path break; } // Check if valid identifier character switch (b) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, 0x80...0xFF => {}, ' ', '\t', '\n', '\r', '.', '#', '>', '+', '~', '[', ':', ')', ']' => break, else => { @branchHint(.cold); return err; }, } i += 1; } // Fast path: no escapes/nulls found if (i == input.len or (i > 0 and input[i] != '\\' and input[i] != 0)) { if (i == 0) { @branchHint(.cold); return err; } self.input = input[i..]; return input[0..i]; } // Slow path: has escapes or nulls var result = try std.ArrayList(u8).initCapacity(arena, input.len); try result.appendSlice(arena, input[0..i]); var j = i; while (j < input.len) { const b = input[j]; if (b == '\\') { j += 1; const escape_result = try parseEscape(input[j..], arena); try result.appendSlice(arena, escape_result.bytes); j += escape_result.consumed; continue; } if (b == 0) { try result.appendSlice(arena, "\u{FFFD}"); j += 1; continue; } const is_ident_char = switch (b) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => true, 0x80...0xFF => true, else => false, }; if (!is_ident_char) { break; } try result.append(arena, b); j += 1; } if (result.items.len == 0) { @branchHint(.cold); return err; } self.input = input[j..]; return result.items; } fn tag(self: *Parser) ![]const u8 { var input = self.input; // First character: must be letter, underscore, or non-ASCII (>= 0x80) // Can also be hyphen if not followed by digit or another hyphen const first = input[0]; if (first == '-') { if (input.len < 2) { @branchHint(.cold); return error.InvalidTagSelector; } const second = input[1]; if (second == '-' or std.ascii.isDigit(second)) { @branchHint(.cold); return error.InvalidTagSelector; } } else if (!std.ascii.isAlphabetic(first) and first != '_' and first < 0x80) { @branchHint(.cold); return error.InvalidTagSelector; } var i: usize = 1; for (input[1..]) |b| { switch (b) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, 0x80...0xFF => {}, // non-ASCII characters ' ', '\t', '\n', '\r' => break, // Stop at selector delimiters '.', '#', '>', '+', '~', '[', ':', ')', ']' => break, else => { @branchHint(.cold); return error.InvalidTagSelector; }, } i += 1; } self.input = input[i..]; return input[0..i]; } fn attribute(self: *Parser, arena: Allocator, page: *Page) !Selector.Attribute { if (comptime IS_DEBUG) { // should have been verified by caller std.debug.assert(self.peek() == '['); } self.input = self.input[1..]; _ = self.skipSpaces(); const attr_name = try self.attributeName(); // Normalize the name to lowercase for fast matching (consistent with Attribute.normalizeNameForLookup) const normalized = try Attribute.normalizeNameForLookup(.wrap(attr_name), page); const name = try normalized.dupe(arena); var case_insensitive = false; _ = self.skipSpaces(); if (self.peek() == ']') { self.input = self.input[1..]; return .{ .name = name, .matcher = .presence, .case_insensitive = case_insensitive }; } const matcher_type = try self.attributeMatcher(); _ = self.skipSpaces(); const value_raw = try self.attributeValue(); const value = try arena.dupe(u8, value_raw); _ = self.skipSpaces(); // Parse optional case-sensitivity flag if (std.ascii.toLower(self.peek()) == 'i') { self.input = self.input[1..]; case_insensitive = true; _ = self.skipSpaces(); } else if (std.ascii.toLower(self.peek()) == 's') { // 's' flag means case-sensitive (explicit) self.input = self.input[1..]; case_insensitive = false; _ = self.skipSpaces(); } if (self.peek() != ']') { return error.InvalidAttributeSelector; } self.input = self.input[1..]; const matcher: Selector.AttributeMatcher = switch (matcher_type) { .exact => .{ .exact = value }, .word => .{ .word = value }, .prefix_dash => .{ .prefix_dash = value }, .starts_with => .{ .starts_with = value }, .ends_with => .{ .ends_with = value }, .substring => .{ .substring = value }, .presence => unreachable, }; return .{ .name = name, .matcher = matcher, .case_insensitive = case_insensitive }; } fn attributeName(self: *Parser) ![]const u8 { const input = self.input; if (input.len == 0) { return error.InvalidAttributeSelector; } const first = input[0]; if (!std.ascii.isAlphabetic(first) and first != '_' and first < 0x80) { return error.InvalidAttributeSelector; } var i: usize = 1; for (input[1..]) |b| { switch (b) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, 0x80...0xFF => {}, else => break, } i += 1; } self.input = input[i..]; return input[0..i]; } fn attributeMatcher(self: *Parser) !std.meta.FieldEnum(Selector.AttributeMatcher) { const input = self.input; if (input.len < 2) { return error.InvalidAttributeSelector; } if (input[0] == '=') { self.input = input[1..]; return .exact; } self.input = input[2..]; return switch (@as(u16, @bitCast(input[0..2].*))) { asUint("~=") => .word, asUint("|=") => .prefix_dash, asUint("^=") => .starts_with, asUint("$=") => .ends_with, asUint("*=") => .substring, else => return error.InvalidAttributeSelector, }; } fn attributeValue(self: *Parser) ![]const u8 { const input = self.input; if (input.len == 0) { return error.InvalidAttributeSelector; } const quote = input[0]; if (quote == '"' or quote == '\'') { const end = std.mem.indexOfScalarPos(u8, input, 1, quote) orelse return error.InvalidAttributeSelector; const value = input[1..end]; self.input = input[end + 1 ..]; return value; } var i: usize = 0; for (input) |b| { switch (b) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {}, 0x80...0xFF => {}, else => break, } i += 1; } if (i == 0) { return error.InvalidAttributeSelector; } const value = input[0..i]; self.input = input[i..]; return value; } fn asUint(comptime string: anytype) std.meta.Int( .unsigned, @bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0 ) { const byteLength = @sizeOf(@TypeOf(string.*)) - 1; const expectedType = *const [byteLength:0]u8; if (@TypeOf(string) != expectedType) { @compileError("expected : " ++ @typeName(expectedType) ++ ", got: " ++ @typeName(@TypeOf(string))); } return @bitCast(@as(*const [byteLength]u8, string).*); } fn fastEql(a: []const u8, comptime b: []const u8) bool { for (a, b) |a_byte, b_byte| { if (a_byte != b_byte) return false; } return true; } const EscapeResult = struct { bytes: []const u8, consumed: usize, // how many bytes from input were consumed }; // Parse CSS escape sequence starting after the backslash // Input should point to the character after '\' // Returns the UTF-8 bytes for the escaped character and how many input bytes were consumed fn parseEscape(input: []const u8, arena: Allocator) !EscapeResult { if (input.len == 0) { // EOF after backslash -> replacement character return .{ .bytes = "\u{FFFD}", .consumed = 0 }; } const first = input[0]; // Check if it's a hex escape (1-6 hex digits) if (std.ascii.isHex(first)) { var hex_value: u32 = 0; var i: usize = 0; // Parse up to 6 hex digits while (i < 6 and i < input.len) : (i += 1) { const c = input[i]; if (!std.ascii.isHex(c)) break; const digit = if (c >= '0' and c <= '9') c - '0' else if (c >= 'a' and c <= 'f') c - 'a' + 10 else if (c >= 'A' and c <= 'F') c - 'A' + 10 else unreachable; hex_value = hex_value * 16 + digit; } var consumed = i; // Consume one optional whitespace character (space, tab, CR, LF, FF) if (i < input.len) { const next = input[i]; if (next == ' ' or next == '\t' or next == '\r' or next == '\n' or next == '\x0C') { consumed += 1; } } // Validate the code point and convert to UTF-8 // Invalid: 0, > 0x10FFFF, or surrogate range 0xD800-0xDFFF if (hex_value == 0 or hex_value > 0x10FFFF or (hex_value >= 0xD800 and hex_value <= 0xDFFF)) { return .{ .bytes = "\u{FFFD}", .consumed = consumed }; } // Encode as UTF-8 var buf = try arena.alloc(u8, 4); const len = std.unicode.utf8Encode(@intCast(hex_value), buf) catch { return .{ .bytes = "\u{FFFD}", .consumed = consumed }; }; return .{ .bytes = buf[0..len], .consumed = consumed }; } // Simple escape - just the character itself var buf = try arena.alloc(u8, 1); buf[0] = first; return .{ .bytes = buf, .consumed = 1 }; } const testing = @import("../../../testing.zig"); test "Selector: Parser.ID" { const arena = testing.allocator; { var parser = Parser{ .input = "#" }; try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "# " }; try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#1" }; try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#9abc" }; try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#-1" }; try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#-5abc" }; try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#--" }; try testing.expectEqual("--", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#--test" }; try testing.expectEqual("--test", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#-" }; try testing.expectError(error.InvalidIDSelector, parser.id(arena)); } { var parser = Parser{ .input = "#over" }; try testing.expectEqual("over", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#myID123" }; try testing.expectEqual("myID123", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#_test" }; try testing.expectEqual("_test", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#test_123" }; try testing.expectEqual("test_123", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#-test" }; try testing.expectEqual("-test", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#my-id" }; try testing.expectEqual("my-id", try parser.id(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "#test other" }; try testing.expectEqual("test", try parser.id(arena)); try testing.expectEqual(" other", parser.input); } { var parser = Parser{ .input = "#id.class" }; try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual(".class", parser.input); } { var parser = Parser{ .input = "#id:hover" }; try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual(":hover", parser.input); } { var parser = Parser{ .input = "#id>child" }; try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual(">child", parser.input); } { var parser = Parser{ .input = "#id[attr]" }; try testing.expectEqual("id", try parser.id(arena)); try testing.expectEqual("[attr]", parser.input); } } test "Selector: Parser.class" { const arena = testing.allocator; { var parser = Parser{ .input = "." }; try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ". " }; try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".1" }; try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".9abc" }; try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".-1" }; try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".-5abc" }; try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".--" }; try testing.expectEqual("--", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".--test" }; try testing.expectEqual("--test", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".-" }; try testing.expectError(error.InvalidClassSelector, parser.class(arena)); } { var parser = Parser{ .input = ".active" }; try testing.expectEqual("active", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".myClass123" }; try testing.expectEqual("myClass123", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "._test" }; try testing.expectEqual("_test", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".test_123" }; try testing.expectEqual("test_123", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".-test" }; try testing.expectEqual("-test", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".my-class" }; try testing.expectEqual("my-class", try parser.class(arena)); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = ".test other" }; try testing.expectEqual("test", try parser.class(arena)); try testing.expectEqual(" other", parser.input); } { var parser = Parser{ .input = ".class1.class2" }; try testing.expectEqual("class1", try parser.class(arena)); try testing.expectEqual(".class2", parser.input); } { var parser = Parser{ .input = ".class:hover" }; try testing.expectEqual("class", try parser.class(arena)); try testing.expectEqual(":hover", parser.input); } { var parser = Parser{ .input = ".class>child" }; try testing.expectEqual("class", try parser.class(arena)); try testing.expectEqual(">child", parser.input); } { var parser = Parser{ .input = ".class[attr]" }; try testing.expectEqual("class", try parser.class(arena)); try testing.expectEqual("[attr]", parser.input); } } test "Selector: Parser.tag" { { var parser = Parser{ .input = "1" }; try testing.expectError(error.InvalidTagSelector, parser.tag()); } { var parser = Parser{ .input = "9abc" }; try testing.expectError(error.InvalidTagSelector, parser.tag()); } { var parser = Parser{ .input = "-1" }; try testing.expectError(error.InvalidTagSelector, parser.tag()); } { var parser = Parser{ .input = "-5abc" }; try testing.expectError(error.InvalidTagSelector, parser.tag()); } { var parser = Parser{ .input = "--" }; try testing.expectError(error.InvalidTagSelector, parser.tag()); } { var parser = Parser{ .input = "--test" }; try testing.expectError(error.InvalidTagSelector, parser.tag()); } { var parser = Parser{ .input = "-" }; try testing.expectError(error.InvalidTagSelector, parser.tag()); } { var parser = Parser{ .input = "div" }; try testing.expectEqual("div", try parser.tag()); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "p" }; try testing.expectEqual("p", try parser.tag()); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "MyCustomElement" }; try testing.expectEqual("MyCustomElement", try parser.tag()); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "_test" }; try testing.expectEqual("_test", try parser.tag()); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "test_123" }; try testing.expectEqual("test_123", try parser.tag()); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "-test" }; try testing.expectEqual("-test", try parser.tag()); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "my-element" }; try testing.expectEqual("my-element", try parser.tag()); try testing.expectEqual("", parser.input); } { var parser = Parser{ .input = "div other" }; try testing.expectEqual("div", try parser.tag()); try testing.expectEqual(" other", parser.input); } { var parser = Parser{ .input = "div.class" }; try testing.expectEqual("div", try parser.tag()); try testing.expectEqual(".class", parser.input); } { var parser = Parser{ .input = "div#id" }; try testing.expectEqual("div", try parser.tag()); try testing.expectEqual("#id", parser.input); } { var parser = Parser{ .input = "div:hover" }; try testing.expectEqual("div", try parser.tag()); try testing.expectEqual(":hover", parser.input); } { var parser = Parser{ .input = "div>child" }; try testing.expectEqual("div", try parser.tag()); try testing.expectEqual(">child", parser.input); } { var parser = Parser{ .input = "div[attr]" }; try testing.expectEqual("div", try parser.tag()); try testing.expectEqual("[attr]", parser.input); } } test "Selector: Parser.parseNthPattern" { { var parser = Parser{ .input = "odd)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(2, pattern.a); try testing.expectEqual(1, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = "even)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(2, pattern.a); try testing.expectEqual(0, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = "3)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(0, pattern.a); try testing.expectEqual(3, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = "2n)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(2, pattern.a); try testing.expectEqual(0, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = "2n+1)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(2, pattern.a); try testing.expectEqual(1, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = "3n-2)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(3, pattern.a); try testing.expectEqual(-2, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = "n)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(1, pattern.a); try testing.expectEqual(0, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = "-n)" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(-1, pattern.a); try testing.expectEqual(0, pattern.b); try testing.expectEqual(")", parser.input); } { var parser = Parser{ .input = " 2n + 1 )" }; const pattern = try parser.parseNthPattern(); try testing.expectEqual(2, pattern.a); try testing.expectEqual(1, pattern.b); try testing.expectEqual(" )", parser.input); } } ================================================ FILE: src/browser/webapi/selector/Selector.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const String = @import("../../../string.zig").String; const Parser = @import("Parser.zig"); const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); pub const List = @import("List.zig"); pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Element { if (input.len == 0) { return error.SyntaxError; } const arena = page.call_arena; const selectors = try Parser.parseList(arena, input, page); for (selectors) |selector| { // Fast path: single compound with only an ID selector if (selector.segments.len == 0 and selector.first.parts.len == 1) { const first = selector.first.parts[0]; if (first == .id) { const el = page.getElementByIdFromNode(root, first.id) orelse continue; // Check if the element is within the root subtree const node = el.asNode(); if (node != root and root.contains(node)) { return el; } continue; } } if (List.initOne(root, selector, page)) |node| { if (node.is(Node.Element)) |el| { return el; } } } return null; } pub fn querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List { if (input.len == 0) { return error.SyntaxError; } const arena = try page.getArena(.{ .debug = "querySelectorAll" }); errdefer page.releaseArena(arena); var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; const selectors = try Parser.parseList(arena, input, page); for (selectors) |selector| { try List.collect(arena, root, selector, &nodes, page); } const list = try arena.create(List); list.* = .{ ._arena = arena, ._nodes = nodes.keys(), }; return list; } pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { if (input.len == 0) { return error.SyntaxError; } const arena = page.call_arena; const selectors = try Parser.parseList(arena, input, page); for (selectors) |selector| { if (List.matches(el.asNode(), selector, el.asNode(), page)) { return true; } } return false; } // Like matches, but allows the caller to specify a scope node distinct from el. // Used by closest() so that :scope always refers to the original context element. pub fn matchesWithScope(el: *Node.Element, input: []const u8, scope: *Node.Element, page: *Page) !bool { if (input.len == 0) { return error.SyntaxError; } const arena = page.call_arena; const selectors = try Parser.parseList(arena, input, page); for (selectors) |selector| { if (List.matches(el.asNode(), selector, scope.asNode(), page)) { return true; } } return false; } pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bool { if (class_name.len == 0 or class_name.len > class_attr.len) return false; var search = class_attr; while (std.mem.indexOf(u8, search, class_name)) |pos| { const is_start = pos == 0 or search[pos - 1] == ' '; const end = pos + class_name.len; const is_end = end == search.len or search[end] == ' '; if (is_start and is_end) return true; search = search[pos + 1 ..]; } return false; } pub const Part = union(enum) { id: []const u8, class: []const u8, tag: Node.Element.Tag, // optimized, for known tags tag_name: []const u8, // fallback for custom/unknown tags universal, // '*' any element pseudo_class: PseudoClass, attribute: Attribute, }; pub const Attribute = struct { name: String, matcher: AttributeMatcher, case_insensitive: bool, }; pub const AttributeMatcher = union(enum) { presence, exact: []const u8, word: []const u8, prefix_dash: []const u8, starts_with: []const u8, ends_with: []const u8, substring: []const u8, }; pub const PseudoClass = union(enum) { // State pseudo-classes modal, checked, disabled, enabled, indeterminate, // Form validation valid, invalid, required, optional, in_range, out_of_range, placeholder_shown, read_only, read_write, default, // User interaction hover, active, focus, focus_within, focus_visible, // Link states link, visited, any_link, target, // Tree structural root, scope, empty, first_child, last_child, only_child, first_of_type, last_of_type, only_of_type, nth_child: NthPattern, nth_last_child: NthPattern, nth_of_type: NthPattern, nth_last_of_type: NthPattern, // Custom elements defined, // Functional lang: []const u8, not: []const Selector, // :not() - CSS Level 4: supports full selectors and comma-separated lists is: []const Selector, // :is() - matches any of the selectors where: []const Selector, // :where() - like :is() but with zero specificity has: []const Selector, // :has() - element containing descendants matching selector }; pub const NthPattern = struct { a: i32, // coefficient (e.g., 2 in "2n+1") b: i32, // offset (e.g., 1 in "2n+1") // Common patterns: // odd: a=2, b=1 // even: a=2, b=0 // 3n+1: a=3, b=1 // 5: a=0, b=5 }; // Combinator represents the relationship between two compound selectors pub const Combinator = enum { descendant, // ' ' - any descendant child, // '>' - direct child next_sibling, // '+' - immediately following sibling subsequent_sibling, // '~' - any following sibling }; // A compound selector is multiple parts that all match the same element // "div.class#id" -> [tag(div), class("class"), id("id")] pub const Compound = struct { parts: []const Part, pub fn format(self: Compound, writer: *std.Io.Writer) !void { for (self.parts) |part| switch (part) { .id => |val| { try writer.writeByte('#'); try writer.writeAll(val); }, .class => |val| { try writer.writeByte('.'); try writer.writeAll(val); }, .tag => |val| try writer.writeAll(@tagName(val)), .tag_name => |val| try writer.writeAll(val), .universal => try writer.writeByte('*'), .pseudo_class => |val| { try writer.writeByte(':'); try writer.writeAll(@tagName(val)); }, .attribute => { try writer.writeAll("TODO"); }, }; } }; // A segment represents a compound selector with the combinator that precedes it pub const Segment = struct { compound: Compound, combinator: Combinator, pub fn format(self: Segment, writer: *std.Io.Writer) !void { switch (self.combinator) { .descendant => try writer.writeByte(' '), .child => try writer.writeAll(" > "), .next_sibling => try writer.writeAll(" + "), .subsequent_sibling => try writer.writeAll(" ~ "), } return self.compound.format(writer); } }; // A full selector is the first compound plus subsequent segments // "div > p + span" -> { first: [tag(div)], segments: [{child, [tag(p)]}, {next_sibling, [tag(span)]}] } pub const Selector = struct { first: Compound, segments: []const Segment, pub fn format(self: Selector, writer: *std.Io.Writer) !void { try self.first.format(writer); for (self.segments) |segment| { try segment.format(writer); } } }; ================================================ FILE: src/browser/webapi/storage/Cookie.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const URL = @import("../../URL.zig"); const log = @import("../../../log.zig"); const DateTime = @import("../../../datetime.zig").DateTime; const public_suffix_list = @import("../../../data/public_suffix_list.zig").lookup; const Cookie = @This(); const max_cookie_size = 4 * 1024; const max_cookie_header_size = 8 * 1024; const max_jar_size = 1024; arena: ArenaAllocator, name: []const u8, value: []const u8, domain: []const u8, path: []const u8, expires: ?f64, secure: bool = false, http_only: bool = false, same_site: SameSite = .none, const SameSite = enum { strict, lax, none, }; pub fn deinit(self: *const Cookie) void { self.arena.deinit(); } // There's https://datatracker.ietf.org/doc/html/rfc6265 but browsers are // far less strict. I only found 2 cases where browsers will reject a cookie: // - a byte 0...31 and 127...255 anywhere in the cookie (the HTTP header // parser might take care of this already) // - any shenanigans with the domain attribute - it has to be the current // domain or one of higher order, excluding TLD. // Anything else, will turn into a cookie. // Single value? That's a cookie with an emtpy name and a value // Key or Values with characters the RFC says aren't allowed? Allowed! ( // (as long as the characters are 32...126) // Invalid attributes? Ignored. // Invalid attribute values? Ignore. // Duplicate attributes - use the last valid // Value-less attributes with a value? Ignore the value pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie { if (str.len > max_cookie_header_size) { return error.CookieHeaderSizeExceeded; } try validateCookieString(str); const cookie_name, const cookie_value, const rest = parseNameValue(str) catch { return error.InvalidNameValue; }; var scrap: [8]u8 = undefined; var path: ?[]const u8 = null; var domain: ?[]const u8 = null; var secure: ?bool = null; var max_age: ?i64 = null; var http_only: ?bool = null; var expires: ?[]const u8 = null; var same_site: ?Cookie.SameSite = null; var it = std.mem.splitScalar(u8, rest, ';'); while (it.next()) |attribute| { const sep = std.mem.indexOfScalarPos(u8, attribute, 0, '=') orelse attribute.len; const key_string = trim(attribute[0..sep]); if (key_string.len > scrap.len) { // not valid, ignore continue; } const key = std.meta.stringToEnum(enum { path, domain, secure, @"max-age", expires, httponly, samesite, }, std.ascii.lowerString(&scrap, key_string)) orelse continue; const value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]); switch (key) { .path => path = value, .domain => domain = value, .secure => secure = true, .@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue, .expires => expires = value, .httponly => http_only = true, .samesite => { if (value.len > scrap.len) { continue; } same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue; }, } } if (same_site == .none and secure == null) { return error.InsecureSameSite; } if (cookie_value.len > max_cookie_size) { return error.CookieSizeExceeded; } var arena = ArenaAllocator.init(allocator); errdefer arena.deinit(); const aa = arena.allocator(); const owned_name = try aa.dupe(u8, cookie_name); const owned_value = try aa.dupe(u8, cookie_value); const owned_path = try parsePath(aa, url, path); const owned_domain = try parseDomain(aa, url, domain); var normalized_expires: ?f64 = null; if (max_age) |ma| { normalized_expires = @floatFromInt(std.time.timestamp() + ma); } else { // max age takes priority over expires if (expires) |expires_| { var exp_dt = DateTime.parse(expires_, .rfc822) catch null; if (exp_dt == null) { if ((expires_.len > 11 and expires_[7] == '-' and expires_[11] == '-')) { // Replace dashes and try again const output = try aa.dupe(u8, expires_); output[7] = ' '; output[11] = ' '; exp_dt = DateTime.parse(output, .rfc822) catch null; } } if (exp_dt) |dt| { normalized_expires = @floatFromInt(dt.unix(.seconds)); } else { // Algolia, for example, will call document.setCookie with // an expired value which is literally 'Invalid Date' // (it's trying to do something like: `new Date() + undefined`). log.debug(.page, "cookie expires date", .{ .date = expires_ }); } } } return .{ .arena = arena, .name = owned_name, .value = owned_value, .path = owned_path, .same_site = same_site orelse .lax, .secure = secure orelse false, .http_only = http_only orelse false, .domain = owned_domain, .expires = normalized_expires, }; } const ValidateCookieError = error{ Empty, InvalidByteSequence }; /// Returns an error if cookie str length is 0 /// or contains characters outside of the ascii range 32...126. fn validateCookieString(str: []const u8) ValidateCookieError!void { if (str.len == 0) { return error.Empty; } const vec_size_suggestion = std.simd.suggestVectorLength(u8); var offset: usize = 0; // Fast path if possible. if (comptime vec_size_suggestion) |size| { while (str.len - offset >= size) : (offset += size) { const Vec = @Vector(size, u8); const space: Vec = @splat(32); const tilde: Vec = @splat(126); const chunk: Vec = str[offset..][0..size].*; // This creates a mask where invalid characters represented // as ones and valid characters as zeros. We then bitCast this // into an unsigned integer. If the integer is not equal to 0, // we know that we've invalid characters in this chunk. // @popCount can also be used but using integers are simpler. const mask = (@intFromBool(chunk < space) | @intFromBool(chunk > tilde)); const reduced: std.meta.Int(.unsigned, size) = @bitCast(mask); // Got match. if (reduced != 0) { return error.InvalidByteSequence; } } // Means str.len % size == 0; we also know str.len != 0. // Cookie is valid. if (offset == str.len) { return; } } // Either remaining slice or the original if fast path not taken. const slice = str[offset..]; // Slow path. const min, const max = std.mem.minMax(u8, slice); if (min < 32 or max > 126) { return error.InvalidByteSequence; } } pub fn parsePath(arena: Allocator, url_: ?[:0]const u8, explicit_path: ?[]const u8) ![]const u8 { // path attribute value either begins with a '/' or we // ignore it and use the "default-path" algorithm if (explicit_path) |path| { if (path.len > 0 and path[0] == '/') { return try arena.dupe(u8, path); } } // default-path const url = url_ orelse return "/"; const url_path = URL.getPathname(url); if (url_path.len == 0 or (url_path.len == 1 and url_path[0] == '/')) { return "/"; } var owned_path: []const u8 = try percentEncode(arena, url_path, isPathChar); const last = std.mem.lastIndexOfScalar(u8, owned_path[1..], '/') orelse { return "/"; }; return try arena.dupe(u8, owned_path[0 .. last + 1]); } pub fn parseDomain(arena: Allocator, url_: ?[:0]const u8, explicit_domain: ?[]const u8) ![]const u8 { var encoded_host: ?[]const u8 = null; if (url_) |url| { const host = try percentEncode(arena, URL.getHostname(url), isHostChar); _ = toLower(host); encoded_host = host; } if (explicit_domain) |domain| { if (domain.len > 0) { const no_leading_dot = if (domain[0] == '.') domain[1..] else domain; var aw = try std.Io.Writer.Allocating.initCapacity(arena, no_leading_dot.len + 1); try aw.writer.writeByte('.'); try std.Uri.Component.percentEncode(&aw.writer, no_leading_dot, isHostChar); const owned_domain = toLower(aw.written()); if (std.mem.indexOfScalarPos(u8, owned_domain, 1, '.') == null and std.mem.eql(u8, "localhost", owned_domain[1..]) == false) { // can't set a cookie for a TLD return error.InvalidDomain; } if (encoded_host) |host| { if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) { return error.InvalidDomain; } } return owned_domain; } } return encoded_host orelse return error.InvalidDomain; // default-domain } pub fn percentEncode(arena: Allocator, part: []const u8, comptime isValidChar: fn (u8) bool) ![]u8 { var aw = try std.Io.Writer.Allocating.initCapacity(arena, part.len); try std.Uri.Component.percentEncode(&aw.writer, part, isValidChar); return aw.written(); // @memory retains memory used before growing } pub fn isHostChar(c: u8) bool { return switch (c) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, ':' => true, '[', ']' => true, else => false, }; } pub fn isPathChar(c: u8) bool { return switch (c) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, '/', ':', '@' => true, else => false, }; } fn parseNameValue(str: []const u8) !struct { []const u8, []const u8, []const u8 } { const key_value_end = std.mem.indexOfScalarPos(u8, str, 0, ';') orelse str.len; const rest = if (key_value_end == str.len) "" else str[key_value_end + 1 ..]; const sep = std.mem.indexOfScalarPos(u8, str[0..key_value_end], 0, '=') orelse { const value = trim(str[0..key_value_end]); if (value.len == 0) { return error.Empty; } return .{ "", value, rest }; }; const name = trim(str[0..sep]); const value = trim(str[sep + 1 .. key_value_end]); return .{ name, value, rest }; } pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, is_navigation: bool, is_http: bool) bool { if (self.http_only and is_http == false) { // http only cookies cannot be accessed from Javascript return false; } if (url.secure == false and self.secure) { // secure cookie can only be sent over HTTPs return false; } if (same_site == false) { // If we aren't on the "same site" (matching 2nd level domain // taking into account public suffix list), then the cookie // can only be sent if cookie.same_site == .none, or if // we're navigating to (as opposed to, say, loading an image) // and cookie.same_site == .lax switch (self.same_site) { .strict => return false, .lax => if (is_navigation == false) return false, .none => {}, } } { if (self.domain[0] == '.') { // When a Set-Cookie header has a Domain attribute // Then we will _always_ prefix it with a dot, extending its // availability to all subdomains (yes, setting the Domain // attributes EXPANDS the domains which the cookie will be // sent to, to always include all subdomains). if (std.mem.eql(u8, url.host, self.domain[1..]) == false and std.mem.endsWith(u8, url.host, self.domain) == false) { return false; } } else if (std.mem.eql(u8, url.host, self.domain) == false) { // When the Domain attribute isn't specific, then the cookie // is only sent on an exact match. return false; } } { if (self.path[self.path.len - 1] == '/') { // If our cookie has a trailing slash, we can only match is // the target path is a perfix. I.e., if our path is // /doc/ we can only match /doc/* if (std.mem.startsWith(u8, url.path, self.path) == false) { return false; } } else { // Our cookie path is something like /hello if (std.mem.startsWith(u8, url.path, self.path) == false) { // The target path has to either be /hello (it isn't) return false; } else if (url.path.len < self.path.len or (url.path.len > self.path.len and url.path[self.path.len] != '/')) { // Or it has to be something like /hello/* (it isn't) // it isn't! return false; } } } return true; } pub const Jar = struct { allocator: Allocator, cookies: std.ArrayList(Cookie), pub fn init(allocator: Allocator) Jar { return .{ .cookies = .{}, .allocator = allocator, }; } pub fn deinit(self: *Jar) void { for (self.cookies.items) |c| { c.deinit(); } self.cookies.deinit(self.allocator); } pub fn clearRetainingCapacity(self: *Jar) void { for (self.cookies.items) |c| { c.deinit(); } self.cookies.clearRetainingCapacity(); } pub fn add( self: *Jar, cookie: Cookie, request_time: i64, ) !void { const is_expired = isCookieExpired(&cookie, request_time); defer if (is_expired) { cookie.deinit(); }; if (self.cookies.items.len >= max_jar_size) { return error.CookieJarQuotaExceeded; } if (cookie.value.len > max_cookie_size) { return error.CookieSizeExceeded; } for (self.cookies.items, 0..) |*c, i| { if (areCookiesEqual(&cookie, c)) { c.deinit(); if (is_expired) { _ = self.cookies.swapRemove(i); } else { self.cookies.items[i] = cookie; } return; } } if (!is_expired) { try self.cookies.append(self.allocator, cookie); } } pub fn removeExpired(self: *Jar, request_time: ?i64) void { if (self.cookies.items.len == 0) return; const time = request_time orelse std.time.timestamp(); var i: usize = self.cookies.items.len; while (i > 0) { i -= 1; const cookie = &self.cookies.items[i]; if (isCookieExpired(cookie, time)) { self.cookies.swapRemove(i).deinit(); } } } pub const LookupOpts = struct { is_http: bool, request_time: ?i64 = null, is_navigation: bool = true, prefix: ?[]const u8 = null, origin_url: ?[:0]const u8 = null, }; pub fn forRequest(self: *Jar, target_url: [:0]const u8, writer: anytype, opts: LookupOpts) !void { const target = PreparedUri{ .host = URL.getHostname(target_url), .path = URL.getPathname(target_url), .secure = URL.isHTTPS(target_url), }; const same_site = try areSameSite(opts.origin_url, target.host); removeExpired(self, opts.request_time); var first = true; for (self.cookies.items) |*cookie| { if (!cookie.appliesTo(&target, same_site, opts.is_navigation, opts.is_http)) { continue; } // we have a match! if (first) { if (opts.prefix) |prefix| { try writer.writeAll(prefix); } first = false; } else { try writer.writeAll("; "); } try writeCookie(cookie, writer); } } pub fn populateFromResponse(self: *Jar, url: [:0]const u8, set_cookie: []const u8) !void { const c = Cookie.parse(self.allocator, url, set_cookie) catch |err| { log.warn(.page, "cookie parse failed", .{ .raw = set_cookie, .err = err }); return; }; const now = std.time.timestamp(); try self.add(c, now); } fn writeCookie(cookie: *const Cookie, writer: anytype) !void { if (cookie.name.len > 0) { try writer.writeAll(cookie.name); try writer.writeByte('='); } if (cookie.value.len > 0) { try writer.writeAll(cookie.value); } } }; fn isCookieExpired(cookie: *const Cookie, now: i64) bool { const ce = cookie.expires orelse return false; return ce <= @as(f64, @floatFromInt(now)); } fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool { if (std.mem.eql(u8, a.name, b.name) == false) { return false; } if (std.mem.eql(u8, a.domain, b.domain) == false) { return false; } if (std.mem.eql(u8, a.path, b.path) == false) { return false; } return true; } fn areSameSite(origin_url_: ?[:0]const u8, target_host: []const u8) !bool { const origin_url = origin_url_ orelse return true; const origin_host = URL.getHostname(origin_url); // common case if (std.mem.eql(u8, target_host, origin_host)) { return true; } return std.mem.eql(u8, findSecondLevelDomain(target_host), findSecondLevelDomain(origin_host)); } fn findSecondLevelDomain(host: []const u8) []const u8 { var i = std.mem.lastIndexOfScalar(u8, host, '.') orelse return host; while (true) { i = std.mem.lastIndexOfScalar(u8, host[0..i], '.') orelse return host; const strip = i + 1; if (public_suffix_list(host[strip..]) == false) { return host[strip..]; } } } pub const PreparedUri = struct { host: []const u8, // Percent encoded, lower case path: []const u8, // Percent encoded secure: bool, // True if scheme is https }; fn trim(str: []const u8) []const u8 { return std.mem.trim(u8, str, &std.ascii.whitespace); } fn trimLeft(str: []const u8) []const u8 { return std.mem.trimLeft(u8, str, &std.ascii.whitespace); } fn trimRight(str: []const u8) []const u8 { return std.mem.trimRight(u8, str, &std.ascii.whitespace); } fn toLower(str: []u8) []u8 { for (str, 0..) |c, i| { str[i] = std.ascii.toLower(c); } return str; } const testing = @import("../../../testing.zig"); const test_url = "http://lightpanda.io/"; test "cookie: findSecondLevelDomain" { const cases = [_]struct { []const u8, []const u8 }{ .{ "", "" }, .{ "com", "com" }, .{ "lightpanda.io", "lightpanda.io" }, .{ "lightpanda.io", "test.lightpanda.io" }, .{ "lightpanda.io", "first.test.lightpanda.io" }, .{ "www.gov.uk", "www.gov.uk" }, .{ "stats.gov.uk", "www.stats.gov.uk" }, .{ "api.gov.uk", "api.gov.uk" }, .{ "dev.api.gov.uk", "dev.api.gov.uk" }, .{ "dev.api.gov.uk", "1.dev.api.gov.uk" }, }; for (cases) |c| { try testing.expectEqual(c.@"0", findSecondLevelDomain(c.@"1")); } } test "Jar: add" { const expectCookies = struct { fn expect(expected: []const struct { []const u8, []const u8 }, jar: Jar) !void { try testing.expectEqual(expected.len, jar.cookies.items.len); LOOP: for (expected) |e| { for (jar.cookies.items) |c| { if (std.mem.eql(u8, e.@"0", c.name) and std.mem.eql(u8, e.@"1", c.value)) { continue :LOOP; } } std.debug.print("Cookie ({s}={s}) not found", .{ e.@"0", e.@"1" }); return error.CookieNotFound; } } }.expect; const now = std.time.timestamp(); var jar = Jar.init(testing.allocator); defer jar.deinit(); try expectCookies(&.{}, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000;Max-Age=0"), now); try expectCookies(&.{}, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000"), now); try expectCookies(&.{.{ "over", "9000" }}, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000!!"), now); try expectCookies(&.{.{ "over", "9000!!" }}, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flow"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flow" } }, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flows;Path=/"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" } }, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9001;Path=/other"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" } }, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9002;Path=/;Domain=lightpanda.io"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" }, .{ "over", "9002" } }, jar); try jar.add(try Cookie.parse(testing.allocator, test_url, "over=x;Path=/other;Max-Age=-200"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9002" } }, jar); } test "Jar: add limit" { var jar = Jar.init(testing.allocator); defer jar.deinit(); const now = std.time.timestamp(); // add a too big cookie value. try testing.expectError(error.CookieSizeExceeded, jar.add(.{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .name = "v", .domain = "lightpanda.io", .path = "/", .expires = null, .value = "v" ** 4096 ++ "v", }, now)); // generate unique names. const names = comptime blk: { @setEvalBranchQuota(max_jar_size); var result: [max_jar_size][]const u8 = undefined; for (0..max_jar_size) |i| { result[i] = "v" ** i; } break :blk result; }; // test the max number limit var i: usize = 0; while (i < max_jar_size) : (i += 1) { const c = Cookie{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .name = names[i], .domain = "lightpanda.io", .path = "/", .expires = null, .value = "v", }; try jar.add(c, now); } try testing.expectError(error.CookieJarQuotaExceeded, jar.add(.{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .name = "last", .domain = "lightpanda.io", .path = "/", .expires = null, .value = "v", }, now)); } test "Jar: forRequest" { const expectCookies = struct { fn expect(expected: []const u8, jar: *Jar, target_url: [:0]const u8, opts: Jar.LookupOpts) !void { var arr: std.ArrayList(u8) = .empty; defer arr.deinit(testing.allocator); try jar.forRequest(target_url, arr.writer(testing.allocator), opts); try testing.expectEqual(expected, arr.items); } }.expect; const now = std.time.timestamp(); var jar = Jar.init(testing.allocator); defer jar.deinit(); const url2 = "http://test.lightpanda.io/"; { // test with no cookies try expectCookies("", &jar, test_url, .{ .is_http = true }); } try jar.add(try Cookie.parse(testing.allocator, test_url, "global1=1"), now); try jar.add(try Cookie.parse(testing.allocator, test_url, "global2=2;Max-Age=30;domain=lightpanda.io"), now); try jar.add(try Cookie.parse(testing.allocator, test_url, "path1=3;Path=/about"), now); try jar.add(try Cookie.parse(testing.allocator, test_url, "path2=4;Path=/docs/"), now); try jar.add(try Cookie.parse(testing.allocator, test_url, "secure=5;Secure"), now); try jar.add(try Cookie.parse(testing.allocator, test_url, "sitenone=6;SameSite=None;Path=/x/;Secure"), now); try jar.add(try Cookie.parse(testing.allocator, test_url, "sitelax=7;SameSite=Lax;Path=/x/"), now); try jar.add(try Cookie.parse(testing.allocator, test_url, "sitestrict=8;SameSite=Strict;Path=/x/"), now); try jar.add(try Cookie.parse(testing.allocator, url2, "domain1=9;domain=test.lightpanda.io"), now); // nothing fancy here try expectCookies("global1=1; global2=2", &jar, test_url, .{ .is_http = true }); try expectCookies("global1=1; global2=2", &jar, test_url, .{ .origin_url = test_url, .is_navigation = false, .is_http = true }); // We have a cookie where Domain=lightpanda.io // This should _not_ match xyxlightpanda.io try expectCookies("", &jar, "http://anothersitelightpanda.io/", .{ .origin_url = test_url, .is_http = true, }); // matching path without trailing / try expectCookies("global1=1; global2=2; path1=3", &jar, "http://lightpanda.io/about", .{ .origin_url = test_url, .is_http = true, }); // incomplete prefix path try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/abou", .{ .origin_url = test_url, .is_http = true, }); // path doesn't match try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/aboutus", .{ .origin_url = test_url, .is_http = true, }); // path doesn't match cookie directory try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/docs", .{ .origin_url = test_url, .is_http = true, }); // exact directory match try expectCookies("global1=1; global2=2; path2=4", &jar, "http://lightpanda.io/docs/", .{ .origin_url = test_url, .is_http = true, }); // sub directory match try expectCookies("global1=1; global2=2; path2=4", &jar, "http://lightpanda.io/docs/more", .{ .origin_url = test_url, .is_http = true, }); // secure try expectCookies("global1=1; global2=2; secure=5", &jar, "https://lightpanda.io/", .{ .origin_url = test_url, .is_http = true, }); // navigational cross domain, secure try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, "https://lightpanda.io/x/", .{ .origin_url = "https://example.com/", .is_http = true, }); // navigational cross domain, insecure try expectCookies("global1=1; global2=2; sitelax=7", &jar, "http://lightpanda.io/x/", .{ .origin_url = "https://example.com/", .is_http = true, }); // non-navigational cross domain, insecure try expectCookies("", &jar, "http://lightpanda.io/x/", .{ .origin_url = "https://example.com/", .is_http = true, .is_navigation = false, }); // non-navigational cross domain, secure try expectCookies("sitenone=6", &jar, "https://lightpanda.io/x/", .{ .origin_url = "https://example.com/", .is_http = true, .is_navigation = false, }); // non-navigational same origin try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, "http://lightpanda.io/x/", .{ .origin_url = "https://lightpanda.io/", .is_http = true, .is_navigation = false, }); // exact domain match + suffix try expectCookies("global2=2; domain1=9", &jar, "http://test.lightpanda.io/", .{ .origin_url = test_url, .is_http = true, }); // domain suffix match + suffix try expectCookies("global2=2; domain1=9", &jar, "http://1.test.lightpanda.io/", .{ .origin_url = test_url, .is_http = true, }); // non-matching domain try expectCookies("global2=2", &jar, "http://other.lightpanda.io/", .{ .origin_url = test_url, .is_http = true, }); const l = jar.cookies.items.len; try expectCookies("global1=1", &jar, test_url, .{ .request_time = now + 100, .origin_url = test_url, .is_http = true, }); try testing.expectEqual(l - 1, jar.cookies.items.len); // If you add more cases after this point, note that the above test removes // the 'global2' cookie } test "Cookie: parse key=value" { try expectError(error.Empty, null, ""); try expectError(error.InvalidByteSequence, null, &.{ 'a', 30, '=', 'b' }); try expectError(error.InvalidByteSequence, null, &.{ 'a', 127, '=', 'b' }); try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 20 }); try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 128 }); try expectAttribute(.{ .name = "", .value = "a" }, null, "a"); try expectAttribute(.{ .name = "", .value = "a" }, null, "a;"); try expectAttribute(.{ .name = "", .value = "a b" }, null, "a b"); try expectAttribute(.{ .name = "a b", .value = "b" }, null, "a b=b"); try expectAttribute(.{ .name = "a,", .value = "b" }, null, "a,=b"); try expectAttribute(.{ .name = ":a>", .value = "b>><" }, null, ":a>=b>><"); try expectAttribute(.{ .name = "abc", .value = "" }, null, "abc="); try expectAttribute(.{ .name = "abc", .value = "" }, null, "abc=;"); try expectAttribute(.{ .name = "a", .value = "b" }, null, "a=b"); try expectAttribute(.{ .name = "a", .value = "b" }, null, "a=b;"); try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f"); try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f "); try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f;"); try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f ;"); try expectAttribute(.{ .name = "abc", .value = "\" fe f\"" }, null, "abc=\" fe f\""); try expectAttribute(.{ .name = "abc", .value = "\" fe f \"" }, null, "abc=\" fe f \""); try expectAttribute(.{ .name = "ab4344c", .value = "1ads23" }, null, " ab4344c=1ads23 "); try expectAttribute(.{ .name = "ab4344c", .value = "1ads23" }, null, " ab4344c = 1ads23 ;"); } test "Cookie: parse path" { try expectAttribute(.{ .path = "/" }, "http://a/", "b"); try expectAttribute(.{ .path = "/" }, "http://a/", "b;path"); try expectAttribute(.{ .path = "/" }, "http://a/", "b;Path="); try expectAttribute(.{ .path = "/" }, "http://a/", "b;Path=;"); try expectAttribute(.{ .path = "/" }, "http://a/", "b; Path=other"); try expectAttribute(.{ .path = "/" }, "http://a/23", "b; path=other "); try expectAttribute(.{ .path = "/" }, "http://a/abc", "b"); try expectAttribute(.{ .path = "/abc" }, "http://a/abc/", "b"); try expectAttribute(.{ .path = "/abc" }, "http://a/abc/123", "b"); try expectAttribute(.{ .path = "/abc/123" }, "http://a/abc/123/", "b"); try expectAttribute(.{ .path = "/a" }, "http://a/", "b;Path=/a"); try expectAttribute(.{ .path = "/aa" }, "http://a/", "b;path=/aa;"); try expectAttribute(.{ .path = "/aabc/" }, "http://a/", "b; path= /aabc/ ;"); try expectAttribute(.{ .path = "/bbb/" }, "http://a/", "b; path=/a/; path=/bbb/"); try expectAttribute(.{ .path = "/cc" }, "http://a/", "b; path=/a/; path=/bbb/; path = /cc"); } test "Cookie: parse secure" { try expectAttribute(.{ .secure = false }, null, "b"); try expectAttribute(.{ .secure = false }, null, "b;secured"); try expectAttribute(.{ .secure = false }, null, "b;security"); try expectAttribute(.{ .secure = false }, null, "b;SecureX"); try expectAttribute(.{ .secure = true }, null, "b; Secure"); try expectAttribute(.{ .secure = true }, null, "b; Secure "); try expectAttribute(.{ .secure = true }, null, "b; Secure=on "); try expectAttribute(.{ .secure = true }, null, "b; Secure=Off "); try expectAttribute(.{ .secure = true }, null, "b; secure=Off "); try expectAttribute(.{ .secure = true }, null, "b; seCUre=Off "); } test "Cookie: parse HttpOnly" { try expectAttribute(.{ .http_only = false }, null, "b"); try expectAttribute(.{ .http_only = false }, null, "b;HttpOnly0"); try expectAttribute(.{ .http_only = false }, null, "b;H ttpOnly"); try expectAttribute(.{ .http_only = true }, null, "b; HttpOnly"); try expectAttribute(.{ .http_only = true }, null, "b; Httponly "); try expectAttribute(.{ .http_only = true }, null, "b; Httponly=on "); try expectAttribute(.{ .http_only = true }, null, "b; httpOnly=Off "); try expectAttribute(.{ .http_only = true }, null, "b; httpOnly=Off "); try expectAttribute(.{ .http_only = true }, null, "b; HttpOnly=Off "); } test "Cookie: parse SameSite" { try expectAttribute(.{ .same_site = .lax }, null, "b;samesite"); try expectAttribute(.{ .same_site = .lax }, null, "b;samesite=lax"); try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Lax "); try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Other "); try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Nope "); // SameSite=none is only valid when Secure is set. The whole cookie is // rejected otherwise try expectError(error.InsecureSameSite, null, "b;samesite=none"); try expectError(error.InsecureSameSite, null, "b;SameSite=None"); try expectAttribute(.{ .same_site = .none }, null, "b; samesite=none; secure "); try expectAttribute(.{ .same_site = .none }, null, "b; SameSite=None ; SECURE"); try expectAttribute(.{ .same_site = .none }, null, "b;Secure; SameSite=None"); try expectAttribute(.{ .same_site = .none }, null, "b; SameSite=None; Secure"); try expectAttribute(.{ .same_site = .strict }, null, "b; samesite=Strict "); try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite= STRICT "); try expectAttribute(.{ .same_site = .strict }, null, "b; SameSITE=strict;"); try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite=Strict"); try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite=None; SameSite=lax; SameSite=Strict"); } test "Cookie: parse max-age" { try expectAttribute(.{ .expires = null }, null, "b;max-age"); try expectAttribute(.{ .expires = null }, null, "b;max-age=abc"); try expectAttribute(.{ .expires = null }, null, "b;max-age=13.22"); try expectAttribute(.{ .expires = null }, null, "b;max-age=13abc"); try expectAttribute(.{ .expires = std.time.timestamp() + 13 }, null, "b;max-age=13"); try expectAttribute(.{ .expires = std.time.timestamp() + -22 }, null, "b;max-age=-22"); try expectAttribute(.{ .expires = std.time.timestamp() + 4294967296 }, null, "b;max-age=4294967296"); try expectAttribute(.{ .expires = std.time.timestamp() + -4294967296 }, null, "b;Max-Age= -4294967296"); try expectAttribute(.{ .expires = std.time.timestamp() + 0 }, null, "b; Max-Age=0"); try expectAttribute(.{ .expires = std.time.timestamp() + 500 }, null, "b; Max-Age = 500 ; Max-Age=invalid"); try expectAttribute(.{ .expires = std.time.timestamp() + 1000 }, null, "b;max-age=600;max-age=0;max-age = 1000"); } test "Cookie: parse expires" { try expectAttribute(.{ .expires = null }, null, "b;expires="); try expectAttribute(.{ .expires = null }, null, "b;expires=abc"); try expectAttribute(.{ .expires = null }, null, "b;expires=13.22"); try expectAttribute(.{ .expires = null }, null, "b;expires=33"); try expectAttribute(.{ .expires = 1918798080 }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT"); try expectAttribute(.{ .expires = 1784275395 }, null, "b;expires=Fri, 17-Jul-2026 08:03:15 GMT"); // max-age has priority over expires try expectAttribute(.{ .expires = std.time.timestamp() + 10 }, null, "b;Max-Age=10; expires=Wed, 21 Oct 2030 07:28:00 GMT"); } test "Cookie: parse all" { try expectCookie(.{ .name = "user-id", .value = "9000", .path = "/cms", .domain = "lightpanda.io", }, "https://lightpanda.io/cms/users", "user-id=9000"); try expectCookie(.{ .name = "user-id", .value = "9000", .path = "/", .http_only = true, .secure = true, .domain = ".lightpanda.io", .expires = @floatFromInt(std.time.timestamp() + 30), }, "https://lightpanda.io/cms/users", "user-id=9000; HttpOnly; Max-Age=30; Secure; path=/; Domain=lightpanda.io"); try expectCookie(.{ .name = "app_session", .value = "123", .path = "/", .http_only = true, .secure = false, .domain = ".localhost", .same_site = .lax, .expires = @floatFromInt(std.time.timestamp() + 7200), }, "http://localhost:8000/login", "app_session=123; Max-Age=7200; path=/; domain=localhost; httponly; samesite=lax"); } test "Cookie: parse domain" { try expectAttribute(.{ .domain = "lightpanda.io" }, "http://lightpanda.io/", "b"); try expectAttribute(.{ .domain = "dev.lightpanda.io" }, "http://dev.lightpanda.io/", "b"); try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://lightpanda.io/", "b;domain=lightpanda.io"); try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://lightpanda.io/", "b;domain=.lightpanda.io"); try expectAttribute(.{ .domain = ".dev.lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=dev.lightpanda.io"); try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=lightpanda.io"); try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=.lightpanda.io"); try expectAttribute(.{ .domain = ".localhost" }, "http://localhost/", "b;domain=localhost"); try expectAttribute(.{ .domain = ".localhost" }, "http://localhost/", "b;domain=.localhost"); try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=io"); try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=.io"); try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.lightpanda.io"); try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.lightpanda.com"); try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.example.com"); } test "Cookie: parse limit" { try expectError(error.CookieHeaderSizeExceeded, "http://lightpanda.io/", "v" ** 8192 ++ ";domain=lightpanda.io"); try expectError(error.CookieSizeExceeded, "http://lightpanda.io/", "v" ** 4096 ++ "v;domain=lightpanda.io"); } const ExpectedCookie = struct { name: []const u8, value: []const u8, path: []const u8, domain: []const u8, expires: ?f64 = null, secure: bool = false, http_only: bool = false, same_site: Cookie.SameSite = .lax, }; fn expectCookie(expected: ExpectedCookie, url: [:0]const u8, set_cookie: []const u8) !void { var cookie = try Cookie.parse(testing.allocator, url, set_cookie); defer cookie.deinit(); try testing.expectEqual(expected.name, cookie.name); try testing.expectEqual(expected.value, cookie.value); try testing.expectEqual(expected.secure, cookie.secure); try testing.expectEqual(expected.http_only, cookie.http_only); try testing.expectEqual(expected.same_site, cookie.same_site); try testing.expectEqual(expected.path, cookie.path); try testing.expectEqual(expected.domain, cookie.domain); try testing.expectDelta(expected.expires, cookie.expires, 2.0); } fn expectAttribute(expected: anytype, url_: ?[:0]const u8, set_cookie: []const u8) !void { var cookie = try Cookie.parse(testing.allocator, url_ orelse test_url, set_cookie); defer cookie.deinit(); inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| { if (comptime std.mem.eql(u8, f.name, "expires")) { switch (@typeInfo(@TypeOf(expected.expires))) { .int, .comptime_int => try testing.expectDelta(@as(f64, @floatFromInt(expected.expires)), cookie.expires, 1.0), else => try testing.expectDelta(expected.expires, cookie.expires, 1.0), } } else { try testing.expectEqual(@field(expected, f.name), @field(cookie, f.name)); } } } fn expectError(expected: anyerror, url: ?[:0]const u8, set_cookie: []const u8) !void { try testing.expectError(expected, Cookie.parse(testing.allocator, url orelse test_url, set_cookie)); } ================================================ FILE: src/browser/webapi/storage/storage.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Allocator = std.mem.Allocator; pub fn registerTypes() []const type { return &.{Lookup}; } pub const Cookie = @import("Cookie.zig"); pub const Shed = struct { _origins: std.StringHashMapUnmanaged(*Bucket) = .empty, pub fn deinit(self: *Shed, allocator: Allocator) void { var it = self._origins.iterator(); while (it.next()) |kv| { allocator.free(kv.key_ptr.*); allocator.destroy(kv.value_ptr.*); } self._origins.deinit(allocator); } pub fn getOrPut(self: *Shed, allocator: Allocator, origin: []const u8) !*Bucket { const gop = try self._origins.getOrPut(allocator, origin); if (gop.found_existing) { return gop.value_ptr.*; } const bucket = try allocator.create(Bucket); errdefer allocator.free(bucket); bucket.* = .{}; gop.key_ptr.* = try allocator.dupe(u8, origin); gop.value_ptr.* = bucket; return bucket; } }; pub const Bucket = struct { local: Lookup = .{}, session: Lookup = .{} }; pub const Lookup = struct { _data: std.StringHashMapUnmanaged([]const u8) = .empty, _size: usize = 0, const max_size = 5 * 1024 * 1024; pub fn getItem(self: *const Lookup, key_: ?[]const u8) ?[]const u8 { const k = key_ orelse return null; return self._data.get(k); } pub fn setItem(self: *Lookup, key_: ?[]const u8, value: []const u8, page: *Page) !void { const k = key_ orelse return; if (self._size + value.len > max_size) { return error.QuotaExceeded; } defer self._size += value.len; const key_owned = try page.dupeString(k); const value_owned = try page.dupeString(value); const gop = try self._data.getOrPut(page.arena, key_owned); gop.value_ptr.* = value_owned; } pub fn removeItem(self: *Lookup, key_: ?[]const u8) void { const k = key_ orelse return; if (self._data.get(k)) |value| { self._size -= value.len; _ = self._data.remove(k); } } pub fn clear(self: *Lookup) void { self._data.clearRetainingCapacity(); self._size = 0; } pub fn key(self: *const Lookup, index: u32) ?[]const u8 { var it = self._data.keyIterator(); var i: u32 = 0; while (it.next()) |k| { if (i == index) { return k.*; } i += 1; } return null; } pub fn getLength(self: *const Lookup) u32 { return @intCast(self._data.count()); } pub const JsApi = struct { pub const bridge = js.Bridge(Lookup); pub const Meta = struct { pub const name = "Storage"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const length = bridge.accessor(Lookup.getLength, null, .{}); pub const getItem = bridge.function(Lookup.getItem, .{}); pub const setItem = bridge.function(Lookup.setItem, .{ .dom_exception = true }); pub const removeItem = bridge.function(Lookup.removeItem, .{}); pub const clear = bridge.function(Lookup.clear, .{}); pub const key = bridge.function(Lookup.key, .{}); pub const @"[str]" = bridge.namedIndexed(Lookup.getItem, Lookup.setItem, null, .{ .null_as_undefined = true }); }; }; const testing = @import("../../../testing.zig"); test "WebApi: Storage" { try testing.htmlRunner("storage.html", .{}); } ================================================ FILE: src/browser/webapi/streams/ReadableStream.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const ReadableStreamDefaultReader = @import("ReadableStreamDefaultReader.zig"); const ReadableStreamDefaultController = @import("ReadableStreamDefaultController.zig"); const WritableStream = @import("WritableStream.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; pub fn registerTypes() []const type { return &.{ ReadableStream, AsyncIterator, }; } const ReadableStream = @This(); pub const State = enum { readable, closed, errored, }; _page: *Page, _state: State, _reader: ?*ReadableStreamDefaultReader, _controller: *ReadableStreamDefaultController, _stored_error: ?[]const u8, _pull_fn: ?js.Function.Global = null, _pulling: bool = false, _pull_again: bool = false, _cancel: ?Cancel = null, const UnderlyingSource = struct { start: ?js.Function = null, pull: ?js.Function.Global = null, cancel: ?js.Function.Global = null, type: ?[]const u8 = null, }; const QueueingStrategy = struct { size: ?js.Function = null, highWaterMark: u32 = 1, }; pub fn init(src_: ?UnderlyingSource, strategy_: ?QueueingStrategy, page: *Page) !*ReadableStream { const strategy: QueueingStrategy = strategy_ orelse .{}; const self = try page._factory.create(ReadableStream{ ._page = page, ._state = .readable, ._reader = null, ._controller = undefined, ._stored_error = null, }); self._controller = try ReadableStreamDefaultController.init(self, strategy.highWaterMark, page); if (src_) |src| { if (src.start) |start| { try start.call(void, .{self._controller}); } if (src.cancel) |callback| { self._cancel = .{ .callback = callback, }; } if (src.pull) |pull| { self._pull_fn = pull; try self.callPullIfNeeded(); } } return self; } pub fn initWithData(data: []const u8, page: *Page) !*ReadableStream { const stream = try init(null, null, page); // For Phase 1: immediately enqueue all data and close try stream._controller.enqueue(.{ .uint8array = .{ .values = data } }); try stream._controller.close(); return stream; } pub fn getReader(self: *ReadableStream, page: *Page) !*ReadableStreamDefaultReader { if (self.getLocked()) { return error.ReaderLocked; } const reader = try ReadableStreamDefaultReader.init(self, page); self._reader = reader; return reader; } pub fn releaseReader(self: *ReadableStream) void { self._reader = null; } pub fn getAsyncIterator(self: *ReadableStream, page: *Page) !*AsyncIterator { return AsyncIterator.init(self, page); } pub fn getLocked(self: *const ReadableStream) bool { return self._reader != null; } pub fn callPullIfNeeded(self: *ReadableStream) !void { if (!self.shouldCallPull()) { return; } if (self._pulling) { self._pull_again = true; return; } self._pulling = true; if (comptime IS_DEBUG) { if (self._page.js.local == null) { log.fatal(.bug, "null context scope", .{ .src = "ReadableStream.callPullIfNeeded", .url = self._page.url }); std.debug.assert(self._page.js.local != null); } } { const func = self._pull_fn orelse return; var ls: js.Local.Scope = undefined; self._page.js.localScope(&ls); defer ls.deinit(); // Call the pull function // Note: In a complete implementation, we'd handle the promise returned by pull // and set _pulling = false when it resolves try ls.toLocal(func).call(void, .{self._controller}); } self._pulling = false; // If pull was requested again while we were pulling, pull again if (self._pull_again) { self._pull_again = false; try self.callPullIfNeeded(); } } fn shouldCallPull(self: *const ReadableStream) bool { if (self._state != .readable) { return false; } if (self._pull_fn == null) { return false; } const desired_size = self._controller.getDesiredSize() orelse return false; return desired_size > 0; } pub fn cancel(self: *ReadableStream, reason: ?[]const u8, page: *Page) !js.Promise { const local = page.js.local.?; if (self._state != .readable) { if (self._cancel) |c| { if (c.resolver) |r| { return local.toLocal(r).promise(); } } return local.resolvePromise(.{}); } if (self._cancel == null) { self._cancel = Cancel{}; } var c = &self._cancel.?; var resolver = blk: { if (c.resolver) |r| { break :blk local.toLocal(r); } var temp = local.createPromiseResolver(); c.resolver = try temp.persist(); break :blk temp; }; // Execute the cancel callback if provided if (c.callback) |cb| { if (reason) |r| { try local.toLocal(cb).call(void, .{r}); } else { try local.toLocal(cb).call(void, .{}); } } self._state = .closed; self._controller._queue.clearRetainingCapacity(); const result = ReadableStreamDefaultReader.ReadResult{ .done = true, .value = .empty, }; for (self._controller._pending_reads.items) |r| { local.toLocal(r).resolve("stream cancelled", result); } self._controller._pending_reads.clearRetainingCapacity(); resolver.resolve("ReadableStream.cancel", {}); return resolver.promise(); } /// pipeThrough(transform) — pipes this readable stream through a transform stream, /// returning the readable side. `transform` is a JS object with `readable` and `writable` properties. const PipeTransform = struct { writable: *WritableStream, readable: *ReadableStream, }; pub fn pipeThrough(self: *ReadableStream, transform: PipeTransform, page: *Page) !*ReadableStream { if (self.getLocked()) { return error.ReaderLocked; } // Start async piping from this stream to the writable side try PipeState.startPipe(self, transform.writable, null, page); return transform.readable; } /// pipeTo(writable) — pipes this readable stream to a writable stream. /// Returns a promise that resolves when piping is complete. pub fn pipeTo(self: *ReadableStream, destination: *WritableStream, page: *Page) !js.Promise { if (self.getLocked()) { return page.js.local.?.rejectPromise("ReadableStream is locked"); } const local = page.js.local.?; var pipe_resolver = local.createPromiseResolver(); const promise = pipe_resolver.promise(); const persisted_resolver = try pipe_resolver.persist(); try PipeState.startPipe(self, destination, persisted_resolver, page); return promise; } /// State for an async pipe operation. const PipeState = struct { reader: *ReadableStreamDefaultReader, writable: *WritableStream, context_id: usize, resolver: ?js.PromiseResolver.Global, fn startPipe( stream: *ReadableStream, writable: *WritableStream, resolver: ?js.PromiseResolver.Global, page: *Page, ) !void { const reader = try stream.getReader(page); const state = try page.arena.create(PipeState); state.* = .{ .reader = reader, .writable = writable, .context_id = page.js.id, .resolver = resolver, }; try state.pumpRead(page); } fn pumpRead(state: *PipeState, page: *Page) !void { const local = page.js.local.?; // Call reader.read() which returns a Promise const read_promise = try state.reader.read(page); // Create JS callback functions for .then() and .catch() const then_fn = local.newCallback(onReadFulfilled, state); const catch_fn = local.newCallback(onReadRejected, state); _ = read_promise.thenAndCatch(then_fn, catch_fn) catch { state.finish(local); }; } const ReadData = struct { done: bool, value: js.Value, }; fn onReadFulfilled(self: *PipeState, data_: ?ReadData, page: *Page) void { const local = page.js.local.?; const data = data_ orelse { return self.finish(local); }; if (data.done) { // Stream is finished, close the writable side self.writable.closeStream(page) catch {}; self.reader.releaseLock(); if (self.resolver) |r| { local.toLocal(r).resolve("pipeTo complete", {}); } return; } const value = data.value; if (value.isUndefined()) { return self.finish(local); } self.writable.writeChunk(value, page) catch { return self.finish(local); }; // Continue reading the next chunk self.pumpRead(page) catch { self.finish(local); }; } fn onReadRejected(self: *PipeState, page: *Page) void { self.finish(page.js.local.?); } fn finish(self: *PipeState, local: *const js.Local) void { self.reader.releaseLock(); if (self.resolver) |r| { local.toLocal(r).resolve("pipe finished", {}); } } }; const Cancel = struct { callback: ?js.Function.Global = null, reason: ?[]const u8 = null, resolver: ?js.PromiseResolver.Global = null, }; pub const JsApi = struct { pub const bridge = js.Bridge(ReadableStream); pub const Meta = struct { pub const name = "ReadableStream"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(ReadableStream.init, .{}); pub const cancel = bridge.function(ReadableStream.cancel, .{}); pub const getReader = bridge.function(ReadableStream.getReader, .{}); pub const pipeThrough = bridge.function(ReadableStream.pipeThrough, .{}); pub const pipeTo = bridge.function(ReadableStream.pipeTo, .{}); pub const locked = bridge.accessor(ReadableStream.getLocked, null, .{}); pub const symbol_async_iterator = bridge.iterator(ReadableStream.getAsyncIterator, .{ .async = true }); }; pub const AsyncIterator = struct { _stream: *ReadableStream, _reader: *ReadableStreamDefaultReader, pub fn init(stream: *ReadableStream, page: *Page) !*AsyncIterator { const reader = try stream.getReader(page); return page._factory.create(AsyncIterator{ ._reader = reader, ._stream = stream, }); } pub fn next(self: *AsyncIterator, page: *Page) !js.Promise { return self._reader.read(page); } pub fn @"return"(self: *AsyncIterator, page: *Page) !js.Promise { self._reader.releaseLock(); return page.js.local.?.resolvePromise(.{ .done = true, .value = null }); } pub const JsApi = struct { pub const bridge = js.Bridge(ReadableStream.AsyncIterator); pub const Meta = struct { pub const name = "ReadableStreamAsyncIterator"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const next = bridge.function(ReadableStream.AsyncIterator.next, .{}); pub const @"return" = bridge.function(ReadableStream.AsyncIterator.@"return", .{}); }; }; const testing = @import("../../../testing.zig"); test "WebApi: ReadableStream" { try testing.htmlRunner("streams/readable_stream.html", .{}); } ================================================ FILE: src/browser/webapi/streams/ReadableStreamDefaultController.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const ReadableStream = @import("ReadableStream.zig"); const ReadableStreamDefaultReader = @import("ReadableStreamDefaultReader.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; const ReadableStreamDefaultController = @This(); pub const Chunk = union(enum) { // the order matters, sorry. uint8array: js.TypedArray(u8), string: []const u8, js_value: js.Value.Global, pub fn dupe(self: Chunk, allocator: std.mem.Allocator) !Chunk { return switch (self) { .string => |str| .{ .string = try allocator.dupe(u8, str) }, .uint8array => |arr| .{ .uint8array = try arr.dupe(allocator) }, .js_value => |val| .{ .js_value = val }, }; } }; _page: *Page, _stream: *ReadableStream, _arena: std.mem.Allocator, _queue: std.ArrayList(Chunk), _pending_reads: std.ArrayList(js.PromiseResolver.Global), _high_water_mark: u32, pub fn init(stream: *ReadableStream, high_water_mark: u32, page: *Page) !*ReadableStreamDefaultController { return page._factory.create(ReadableStreamDefaultController{ ._page = page, ._queue = .empty, ._stream = stream, ._arena = page.arena, ._pending_reads = .empty, ._high_water_mark = high_water_mark, }); } pub fn addPendingRead(self: *ReadableStreamDefaultController, page: *Page) !js.Promise { const resolver = page.js.local.?.createPromiseResolver(); try self._pending_reads.append(self._arena, try resolver.persist()); return resolver.promise(); } pub fn enqueue(self: *ReadableStreamDefaultController, chunk: Chunk) !void { if (self._stream._state != .readable) { return error.StreamNotReadable; } if (self._pending_reads.items.len == 0) { const chunk_copy = try chunk.dupe(self._page.arena); return self._queue.append(self._arena, chunk_copy); } // I know, this is ouch! But we expect to have very few (if any) // pending reads. const resolver = self._pending_reads.orderedRemove(0); const result = ReadableStreamDefaultReader.ReadResult{ .done = false, .value = .fromChunk(chunk), }; if (comptime IS_DEBUG) { if (self._page.js.local == null) { log.fatal(.bug, "null context scope", .{ .src = "ReadableStreamDefaultController.enqueue", .url = self._page.url }); std.debug.assert(self._page.js.local != null); } } var ls: js.Local.Scope = undefined; self._page.js.localScope(&ls); defer ls.deinit(); ls.toLocal(resolver).resolve("stream enqueue", result); } /// Enqueue a raw JS value, preserving its type (number, bool, object, etc.). /// Used by the JS-facing API; internal Zig callers should use enqueue(Chunk). pub fn enqueueValue(self: *ReadableStreamDefaultController, value: js.Value) !void { if (self._stream._state != .readable) { return error.StreamNotReadable; } if (self._pending_reads.items.len == 0) { const persisted = try value.persist(); try self._queue.append(self._arena, .{ .js_value = persisted }); return; } const resolver = self._pending_reads.orderedRemove(0); const persisted = try value.persist(); const result = ReadableStreamDefaultReader.ReadResult{ .done = false, .value = .{ .js_value = persisted }, }; if (comptime IS_DEBUG) { if (self._page.js.local == null) { log.fatal(.bug, "null context scope", .{ .src = "ReadableStreamDefaultController.enqueueValue", .url = self._page.url }); std.debug.assert(self._page.js.local != null); } } var ls: js.Local.Scope = undefined; self._page.js.localScope(&ls); defer ls.deinit(); ls.toLocal(resolver).resolve("stream enqueue value", result); } pub fn close(self: *ReadableStreamDefaultController) !void { if (self._stream._state != .readable) { return error.StreamNotReadable; } self._stream._state = .closed; // Resolve all pending reads with done=true const result = ReadableStreamDefaultReader.ReadResult{ .done = true, .value = .empty, }; if (comptime IS_DEBUG) { if (self._page.js.local == null) { log.fatal(.bug, "null context scope", .{ .src = "ReadableStreamDefaultController.close", .url = self._page.url }); std.debug.assert(self._page.js.local != null); } } for (self._pending_reads.items) |resolver| { var ls: js.Local.Scope = undefined; self._page.js.localScope(&ls); defer ls.deinit(); ls.toLocal(resolver).resolve("stream close", result); } self._pending_reads.clearRetainingCapacity(); } pub fn doError(self: *ReadableStreamDefaultController, err: []const u8) !void { if (self._stream._state != .readable) { return; } self._stream._state = .errored; self._stream._stored_error = try self._page.arena.dupe(u8, err); // Reject all pending reads for (self._pending_reads.items) |resolver| { self._page.js.toLocal(resolver).reject("stream errror", err); } self._pending_reads.clearRetainingCapacity(); } pub fn dequeue(self: *ReadableStreamDefaultController) ?Chunk { if (self._queue.items.len == 0) { return null; } const chunk = self._queue.orderedRemove(0); // After dequeueing, we may need to pull more data self._stream.callPullIfNeeded() catch {}; return chunk; } pub fn getDesiredSize(self: *const ReadableStreamDefaultController) ?i32 { switch (self._stream._state) { .errored => return null, .closed => return 0, .readable => { const queue_size: i32 = @intCast(self._queue.items.len); const hwm: i32 = @intCast(self._high_water_mark); return hwm - queue_size; }, } } pub const JsApi = struct { pub const bridge = js.Bridge(ReadableStreamDefaultController); pub const Meta = struct { pub const name = "ReadableStreamDefaultController"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const enqueue = bridge.function(ReadableStreamDefaultController.enqueueValue, .{}); pub const close = bridge.function(ReadableStreamDefaultController.close, .{}); pub const @"error" = bridge.function(ReadableStreamDefaultController.doError, .{}); pub const desiredSize = bridge.accessor(ReadableStreamDefaultController.getDesiredSize, null, .{}); }; ================================================ FILE: src/browser/webapi/streams/ReadableStreamDefaultReader.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const ReadableStream = @import("ReadableStream.zig"); const ReadableStreamDefaultController = @import("ReadableStreamDefaultController.zig"); const ReadableStreamDefaultReader = @This(); _page: *Page, _stream: ?*ReadableStream, pub fn init(stream: *ReadableStream, page: *Page) !*ReadableStreamDefaultReader { return page._factory.create(ReadableStreamDefaultReader{ ._stream = stream, ._page = page, }); } pub const ReadResult = struct { done: bool, value: Chunk, // Done like this so that we can properly return undefined in some cases const Chunk = union(enum) { empty, string: []const u8, uint8array: js.TypedArray(u8), js_value: js.Value.Global, pub fn fromChunk(chunk: ReadableStreamDefaultController.Chunk) Chunk { return switch (chunk) { .string => |s| .{ .string = s }, .uint8array => |arr| .{ .uint8array = arr }, .js_value => |val| .{ .js_value = val }, }; } }; }; pub fn read(self: *ReadableStreamDefaultReader, page: *Page) !js.Promise { const stream = self._stream orelse { return page.js.local.?.rejectPromise("Reader has been released"); }; if (stream._state == .errored) { const err = stream._stored_error orelse "Stream errored"; return page.js.local.?.rejectPromise(err); } if (stream._controller.dequeue()) |chunk| { const result = ReadResult{ .done = false, .value = .fromChunk(chunk), }; return page.js.local.?.resolvePromise(result); } if (stream._state == .closed) { const result = ReadResult{ .done = true, .value = .empty, }; return page.js.local.?.resolvePromise(result); } // No data, but not closed. We need to queue the read for any future data return stream._controller.addPendingRead(page); } pub fn releaseLock(self: *ReadableStreamDefaultReader) void { if (self._stream) |stream| { stream.releaseReader(); self._stream = null; } } pub fn cancel(self: *ReadableStreamDefaultReader, reason_: ?[]const u8, page: *Page) !js.Promise { const stream = self._stream orelse { return page.js.local.?.rejectPromise("Reader has been released"); }; self.releaseLock(); return stream.cancel(reason_, page); } pub const JsApi = struct { pub const bridge = js.Bridge(ReadableStreamDefaultReader); pub const Meta = struct { pub const name = "ReadableStreamDefaultReader"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const read = bridge.function(ReadableStreamDefaultReader.read, .{}); pub const cancel = bridge.function(ReadableStreamDefaultReader.cancel, .{}); pub const releaseLock = bridge.function(ReadableStreamDefaultReader.releaseLock, .{}); }; ================================================ FILE: src/browser/webapi/streams/TransformStream.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const ReadableStream = @import("ReadableStream.zig"); const ReadableStreamDefaultController = @import("ReadableStreamDefaultController.zig"); const WritableStream = @import("WritableStream.zig"); const TransformStream = @This(); pub const DefaultController = TransformStreamDefaultController; pub const ZigTransformFn = *const fn (*TransformStreamDefaultController, js.Value) anyerror!void; _readable: *ReadableStream, _writable: *WritableStream, _controller: *TransformStreamDefaultController, const Transformer = struct { start: ?js.Function = null, transform: ?js.Function.Global = null, flush: ?js.Function.Global = null, }; pub fn init(transformer_: ?Transformer, page: *Page) !*TransformStream { const readable = try ReadableStream.init(null, null, page); const self = try page._factory.create(TransformStream{ ._readable = readable, ._writable = undefined, ._controller = undefined, }); const transform_controller = try TransformStreamDefaultController.init( self, if (transformer_) |t| t.transform else null, if (transformer_) |t| t.flush else null, null, page, ); self._controller = transform_controller; self._writable = try WritableStream.initForTransform(self, page); if (transformer_) |transformer| { if (transformer.start) |start| { try start.call(void, .{transform_controller}); } } return self; } pub fn initWithZigTransform(zig_transform: ZigTransformFn, page: *Page) !*TransformStream { const readable = try ReadableStream.init(null, null, page); const self = try page._factory.create(TransformStream{ ._readable = readable, ._writable = undefined, ._controller = undefined, }); const transform_controller = try TransformStreamDefaultController.init(self, null, null, zig_transform, page); self._controller = transform_controller; self._writable = try WritableStream.initForTransform(self, page); return self; } pub fn transformWrite(self: *TransformStream, chunk: js.Value, page: *Page) !void { if (self._controller._zig_transform_fn) |zig_fn| { // Zig-level transform (used by TextEncoderStream etc.) try zig_fn(self._controller, chunk); return; } if (self._controller._transform_fn) |transform_fn| { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); try ls.toLocal(transform_fn).call(void, .{ chunk, self._controller }); } else { try self._readable._controller.enqueue(.{ .string = try chunk.toStringSlice() }); } } pub fn transformClose(self: *TransformStream, page: *Page) !void { if (self._controller._flush_fn) |flush_fn| { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); try ls.toLocal(flush_fn).call(void, .{self._controller}); } try self._readable._controller.close(); } pub fn getReadable(self: *const TransformStream) *ReadableStream { return self._readable; } pub fn getWritable(self: *const TransformStream) *WritableStream { return self._writable; } pub const JsApi = struct { pub const bridge = js.Bridge(TransformStream); pub const Meta = struct { pub const name = "TransformStream"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(TransformStream.init, .{}); pub const readable = bridge.accessor(TransformStream.getReadable, null, .{}); pub const writable = bridge.accessor(TransformStream.getWritable, null, .{}); }; pub fn registerTypes() []const type { return &.{ TransformStream, TransformStreamDefaultController, }; } pub const TransformStreamDefaultController = struct { _stream: *TransformStream, _transform_fn: ?js.Function.Global, _flush_fn: ?js.Function.Global, _zig_transform_fn: ?ZigTransformFn, pub fn init( stream: *TransformStream, transform_fn: ?js.Function.Global, flush_fn: ?js.Function.Global, zig_transform_fn: ?ZigTransformFn, page: *Page, ) !*TransformStreamDefaultController { return page._factory.create(TransformStreamDefaultController{ ._stream = stream, ._transform_fn = transform_fn, ._flush_fn = flush_fn, ._zig_transform_fn = zig_transform_fn, }); } pub fn enqueue(self: *TransformStreamDefaultController, chunk: ReadableStreamDefaultController.Chunk) !void { try self._stream._readable._controller.enqueue(chunk); } /// Enqueue a raw JS value, preserving its type. Used by the JS-facing API. pub fn enqueueValue(self: *TransformStreamDefaultController, value: js.Value) !void { try self._stream._readable._controller.enqueueValue(value); } pub fn doError(self: *TransformStreamDefaultController, reason: []const u8) !void { try self._stream._readable._controller.doError(reason); } pub fn terminate(self: *TransformStreamDefaultController) !void { try self._stream._readable._controller.close(); } pub const JsApi = struct { pub const bridge = js.Bridge(TransformStreamDefaultController); pub const Meta = struct { pub const name = "TransformStreamDefaultController"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const enqueue = bridge.function(TransformStreamDefaultController.enqueueValue, .{}); pub const @"error" = bridge.function(TransformStreamDefaultController.doError, .{}); pub const terminate = bridge.function(TransformStreamDefaultController.terminate, .{}); }; }; ================================================ FILE: src/browser/webapi/streams/WritableStream.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const WritableStreamDefaultWriter = @import("WritableStreamDefaultWriter.zig"); const WritableStreamDefaultController = @import("WritableStreamDefaultController.zig"); const TransformStream = @import("TransformStream.zig"); const WritableStream = @This(); pub const State = enum { writable, closed, errored, }; _state: State, _writer: ?*WritableStreamDefaultWriter, _controller: *WritableStreamDefaultController, _stored_error: ?[]const u8, _write_fn: ?js.Function.Global, _close_fn: ?js.Function.Global, _transform_stream: ?*TransformStream, const UnderlyingSink = struct { start: ?js.Function = null, write: ?js.Function.Global = null, close: ?js.Function.Global = null, abort: ?js.Function.Global = null, type: ?[]const u8 = null, }; pub fn init(sink_: ?UnderlyingSink, page: *Page) !*WritableStream { const self = try page._factory.create(WritableStream{ ._state = .writable, ._writer = null, ._controller = undefined, ._stored_error = null, ._write_fn = null, ._close_fn = null, ._transform_stream = null, }); self._controller = try WritableStreamDefaultController.init(self, page); if (sink_) |sink| { if (sink.start) |start| { try start.call(void, .{self._controller}); } self._write_fn = sink.write; self._close_fn = sink.close; } return self; } pub fn initForTransform(transform_stream: *TransformStream, page: *Page) !*WritableStream { const self = try page._factory.create(WritableStream{ ._state = .writable, ._writer = null, ._controller = undefined, ._stored_error = null, ._write_fn = null, ._close_fn = null, ._transform_stream = transform_stream, }); self._controller = try WritableStreamDefaultController.init(self, page); return self; } pub fn getWriter(self: *WritableStream, page: *Page) !*WritableStreamDefaultWriter { if (self.getLocked()) { return error.WriterLocked; } const writer = try WritableStreamDefaultWriter.init(self, page); self._writer = writer; return writer; } pub fn getLocked(self: *const WritableStream) bool { return self._writer != null; } pub fn writeChunk(self: *WritableStream, chunk: js.Value, page: *Page) !void { if (self._state != .writable) return; if (self._transform_stream) |ts| { try ts.transformWrite(chunk, page); return; } if (self._write_fn) |write_fn| { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); try ls.toLocal(write_fn).call(void, .{ chunk, self._controller }); } } pub fn closeStream(self: *WritableStream, page: *Page) !void { if (self._state != .writable) return; self._state = .closed; if (self._transform_stream) |ts| { try ts.transformClose(page); return; } if (self._close_fn) |close_fn| { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); try ls.toLocal(close_fn).call(void, .{self._controller}); } } pub const JsApi = struct { pub const bridge = js.Bridge(WritableStream); pub const Meta = struct { pub const name = "WritableStream"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const constructor = bridge.constructor(WritableStream.init, .{}); pub const getWriter = bridge.function(WritableStream.getWriter, .{}); pub const locked = bridge.accessor(WritableStream.getLocked, null, .{}); }; pub fn registerTypes() []const type { return &.{ WritableStream, }; } ================================================ FILE: src/browser/webapi/streams/WritableStreamDefaultController.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const WritableStream = @import("WritableStream.zig"); const WritableStreamDefaultController = @This(); _stream: *WritableStream, pub fn init(stream: *WritableStream, page: *Page) !*WritableStreamDefaultController { return page._factory.create(WritableStreamDefaultController{ ._stream = stream, }); } pub fn doError(self: *WritableStreamDefaultController, reason: []const u8) void { if (self._stream._state != .writable) return; self._stream._state = .errored; self._stream._stored_error = reason; } pub const JsApi = struct { pub const bridge = js.Bridge(WritableStreamDefaultController); pub const Meta = struct { pub const name = "WritableStreamDefaultController"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const @"error" = bridge.function(WritableStreamDefaultController.doError, .{}); }; ================================================ FILE: src/browser/webapi/streams/WritableStreamDefaultWriter.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const WritableStream = @import("WritableStream.zig"); const WritableStreamDefaultWriter = @This(); _stream: ?*WritableStream, pub fn init(stream: *WritableStream, page: *Page) !*WritableStreamDefaultWriter { return page._factory.create(WritableStreamDefaultWriter{ ._stream = stream, }); } pub fn write(self: *WritableStreamDefaultWriter, chunk: js.Value, page: *Page) !js.Promise { const stream = self._stream orelse { return page.js.local.?.rejectPromise("Writer has been released"); }; if (stream._state != .writable) { return page.js.local.?.rejectPromise("Stream is not writable"); } try stream.writeChunk(chunk, page); return page.js.local.?.resolvePromise(.{}); } pub fn close(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise { const stream = self._stream orelse { return page.js.local.?.rejectPromise("Writer has been released"); }; if (stream._state != .writable) { return page.js.local.?.rejectPromise("Stream is not writable"); } try stream.closeStream(page); return page.js.local.?.resolvePromise(.{}); } pub fn releaseLock(self: *WritableStreamDefaultWriter) void { if (self._stream) |stream| { stream._writer = null; self._stream = null; } } pub fn getClosed(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise { const stream = self._stream orelse { return page.js.local.?.rejectPromise("Writer has been released"); }; if (stream._state == .closed) { return page.js.local.?.resolvePromise(.{}); } return page.js.local.?.resolvePromise(.{}); } pub fn getDesiredSize(self: *const WritableStreamDefaultWriter) ?i32 { const stream = self._stream orelse return null; return switch (stream._state) { .writable => 1, .closed => 0, .errored => null, }; } pub fn getReady(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise { _ = self; return page.js.local.?.resolvePromise(.{}); } pub const JsApi = struct { pub const bridge = js.Bridge(WritableStreamDefaultWriter); pub const Meta = struct { pub const name = "WritableStreamDefaultWriter"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const write = bridge.function(WritableStreamDefaultWriter.write, .{}); pub const close = bridge.function(WritableStreamDefaultWriter.close, .{}); pub const releaseLock = bridge.function(WritableStreamDefaultWriter.releaseLock, .{}); pub const closed = bridge.accessor(WritableStreamDefaultWriter.getClosed, null, .{}); pub const ready = bridge.accessor(WritableStreamDefaultWriter.getReady, null, .{}); pub const desiredSize = bridge.accessor(WritableStreamDefaultWriter.getDesiredSize, null, .{}); }; ================================================ FILE: src/cdp/AXNode.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const jsonStringify = std.json.Stringify; const log = @import("../log.zig"); const Page = @import("../browser/Page.zig"); const DOMNode = @import("../browser/webapi/Node.zig"); const Node = @import("Node.zig"); const AXNode = @This(); // Need a custom writer, because we can't just serialize the node as-is. // Sometimes we want to serializ the node without chidren, sometimes with just // its direct children, and sometimes the entire tree. // (For now, we only support direct children) pub const Writer = struct { root: *const Node, registry: *Node.Registry, page: *Page, pub const Opts = struct {}; pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { self.toJSON(self.root, w) catch |err| { // The only error our jsonStringify method can return is // @TypeOf(w).Error. In other words, our code can't return its own // error, we can only return a writer error. Kinda sucks. log.err(.cdp, "node toJSON stringify", .{ .err = err }); return error.WriteFailed; }; } fn toJSON(self: *const Writer, node: *const Node, w: anytype) !void { try w.beginArray(); const root = AXNode.fromNode(node.dom); if (try self.writeNode(node.id, root, w)) { try self.writeNodeChildren(root, w); } return w.endArray(); } fn writeNodeChildren(self: *const Writer, parent: AXNode, w: anytype) !void { // Add ListMarker for listitem elements if (parent.dom.is(DOMNode.Element)) |parent_el| { if (parent_el.getTag() == .li) { try self.writeListMarker(parent.dom, w); } } var it = parent.dom.childrenIterator(); const ignore_text = ignoreText(parent.dom); while (it.next()) |dom_node| { switch (dom_node._type) { .cdata => { if (dom_node.is(DOMNode.CData.Text) == null) { continue; } if (ignore_text) { continue; } }, .element => {}, else => continue, } const node = try self.registry.register(dom_node); const axn = AXNode.fromNode(node.dom); if (try self.writeNode(node.id, axn, w)) { try self.writeNodeChildren(axn, w); } } } fn writeListMarker(self: *const Writer, li_node: *DOMNode, w: anytype) !void { // Find the parent list element const parent = li_node._parent orelse return; const parent_el = parent.is(DOMNode.Element) orelse return; const list_type = parent_el.getTag(); // Only create markers for actual list elements switch (list_type) { .ul, .ol, .menu => {}, else => return, } // Write the ListMarker node try w.beginObject(); // Use the next available ID for the marker try w.objectField("nodeId"); const marker_id = self.registry.node_id; self.registry.node_id += 1; try w.write(marker_id); try w.objectField("backendDOMNodeId"); try w.write(marker_id); try w.objectField("role"); try self.writeAXValue(.{ .role = "ListMarker" }, w); try w.objectField("ignored"); try w.write(false); try w.objectField("name"); try w.beginObject(); try w.objectField("type"); try w.write("computedString"); try w.objectField("value"); // Write marker text directly based on list type switch (list_type) { .ul, .menu => try w.write("• "), .ol => { // Calculate the list item number by counting preceding li siblings var count: usize = 1; var it = parent.childrenIterator(); while (it.next()) |child| { if (child == li_node) break; if (child.is(DOMNode.Element.Html) == null) continue; const child_el = child.as(DOMNode.Element); if (child_el.getTag() == .li) count += 1; } // Sanity check: lists with >9999 items are unrealistic if (count > 9999) return error.ListTooLong; // Use a small stack buffer to format the number (max "9999. " = 6 chars) var buf: [6]u8 = undefined; const marker_text = try std.fmt.bufPrint(&buf, "{d}. ", .{count}); try w.write(marker_text); }, else => unreachable, } try w.objectField("sources"); try w.beginArray(); try w.beginObject(); try w.objectField("type"); try w.write("contents"); try w.endObject(); try w.endArray(); try w.endObject(); try w.objectField("properties"); try w.beginArray(); try w.endArray(); // Get the parent node ID for the parentId field const li_registered = try self.registry.register(li_node); try w.objectField("parentId"); try w.write(li_registered.id); try w.objectField("childIds"); try w.beginArray(); try w.endArray(); try w.endObject(); } const AXValue = union(enum) { role: []const u8, string: []const u8, computedString: []const u8, integer: usize, boolean: bool, booleanOrUndefined: bool, token: []const u8, // TODO not implemented: // tristate, idrefList, node, nodeList, number, tokenList, // domRelation, internalRole, valueUndefined, }; fn writeAXSource(_: *const Writer, source: AXSource, w: anytype) !void { try w.objectField("sources"); try w.beginArray(); try w.beginObject(); // attribute, implicit, style, contents, placeholder, relatedElement const source_type = switch (source) { .aria_labelledby => blk: { try w.objectField("attribute"); try w.write(@tagName(source)); break :blk "relatedElement"; }, .aria_label, .alt, .title, .placeholder, .value => blk: { // No sure if it's correct for .value case. try w.objectField("attribute"); try w.write(@tagName(source)); break :blk "attribute"; }, // Chrome sends the content AXValue *again* in the source. // But It seems useless to me. // // w.objectField("value"); // self.writeAXValue(.{ .type = .computedString, .value = value.value }, w); .contents => "contents", .label_element, .label_wrap => "TODO", // TODO }; try w.objectField("type"); try w.write(source_type); try w.endObject(); try w.endArray(); } fn writeAXValue(_: *const Writer, value: AXValue, w: anytype) !void { try w.beginObject(); try w.objectField("type"); try w.write(@tagName(std.meta.activeTag(value))); try w.objectField("value"); switch (value) { .integer => |v| { // CDP spec requires integer values to be serialized as strings. // 20 bytes is enough for the decimal representation of a 64-bit integer. var buf: [20]u8 = undefined; const s = try std.fmt.bufPrint(&buf, "{d}", .{v}); try w.write(s); }, inline else => |v| try w.write(v), } try w.endObject(); } const AXProperty = struct { // zig fmt: off name: enum(u8) { actions, busy, disabled, editable, focusable, focused, hidden, hiddenRoot, invalid, keyshortcuts, settable, roledescription, live, atomic, relevant, root, autocomplete, hasPopup, level, multiselectable, orientation, multiline, readonly, required, valuemin, valuemax, valuetext, checked, expanded, modal, pressed, selected, activedescendant, controls, describedby, details, errormessage, flowto, labelledby, owns, url, activeFullscreenElement, activeModalDialog, activeAriaModalDialog, ariaHiddenElement, ariaHiddenSubtree, emptyAlt, emptyText, inertElement, inertSubtree, labelContainer, labelFor, notRendered, notVisible, presentationalRole, probablyPresentational, inactiveCarouselTabContent, uninteresting, }, // zig fmt: on value: AXValue, }; fn writeAXProperties(self: *const Writer, axnode: AXNode, w: anytype) !void { const dom_node = axnode.dom; const page = self.page; switch (dom_node._type) { .document => |document| { const uri = document.getURL(page); try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w); try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); try self.writeAXProperty(.{ .name = .focused, .value = .{ .booleanOrUndefined = true } }, w); return; }, .cdata => return, .element => |el| switch (el.getTag()) { .h1 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 1 } }, w), .h2 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 2 } }, w), .h3 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 3 } }, w), .h4 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 4 } }, w), .h5 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 5 } }, w), .h6 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 6 } }, w), .img => { const img = el.as(DOMNode.Element.Html.Image); const uri = try img.getSrc(self.page); if (uri.len == 0) return; try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w); }, .anchor => { const a = el.as(DOMNode.Element.Html.Anchor); const uri = try a.getHref(self.page); if (uri.len == 0) return; try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w); try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); }, .input => { const input = el.as(DOMNode.Element.Html.Input); const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); switch (input._input_type) { .text, .email, .tel, .url, .search, .password, .number => { if (is_disabled) { try self.writeAXProperty(.{ .name = .disabled, .value = .{ .boolean = true } }, w); } try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .editable, .value = .{ .token = "plaintext" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .multiline, .value = .{ .boolean = false } }, w); try self.writeAXProperty(.{ .name = .readonly, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("readonly")) } }, w); try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w); }, .button, .submit, .reset, .image => { try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } }, .checkbox, .radio => { try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } const is_checked = el.hasAttributeSafe(comptime .wrap("checked")); try self.writeAXProperty(.{ .name = .checked, .value = .{ .token = if (is_checked) "true" else "false" } }, w); }, else => {}, } }, .textarea => { const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .editable, .value = .{ .token = "plaintext" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .multiline, .value = .{ .boolean = true } }, w); try self.writeAXProperty(.{ .name = .readonly, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("readonly")) } }, w); try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w); }, .select => { const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .hasPopup, .value = .{ .token = "menu" } }, w); try self.writeAXProperty(.{ .name = .expanded, .value = .{ .booleanOrUndefined = false } }, w); }, .option => { const option = el.as(DOMNode.Element.Html.Option); try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); // Check if this option is selected by examining the parent select const is_selected = blk: { // First check if explicitly selected if (option.getSelected()) break :blk true; // Check if implicitly selected (first enabled option in select with no explicit selection) const parent = dom_node._parent orelse break :blk false; const parent_el = parent.as(DOMNode.Element); if (parent_el.getTag() != .select) break :blk false; const select = parent_el.as(DOMNode.Element.Html.Select); const selected_idx = select.getSelectedIndex(); // Find this option's index var idx: i32 = 0; var it = parent.childrenIterator(); while (it.next()) |child| { if (child.is(DOMNode.Element.Html.Option) == null) continue; if (child == dom_node) { break :blk idx == selected_idx; } idx += 1; } break :blk false; }; if (is_selected) { try self.writeAXProperty(.{ .name = .selected, .value = .{ .booleanOrUndefined = true } }, w); } }, .button => { const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } }, .hr => { try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w); try self.writeAXProperty(.{ .name = .orientation, .value = .{ .token = "horizontal" } }, w); }, .li => { // Calculate level by counting list ancestors (ul, ol, menu) var level: usize = 0; var current = dom_node._parent; while (current) |node| { if (node.is(DOMNode.Element) == null) { current = node._parent; continue; } const current_el = node.as(DOMNode.Element); switch (current_el.getTag()) { .ul, .ol, .menu => level += 1, else => {}, } current = node._parent; } try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = level } }, w); }, else => {}, }, else => |tag| { log.debug(.cdp, "invalid tag", .{ .tag = tag }); return error.InvalidTag; }, } } fn writeAXProperty(self: *const Writer, value: AXProperty, w: anytype) !void { try w.beginObject(); try w.objectField("name"); try w.write(@tagName(value.name)); try w.objectField("value"); try self.writeAXValue(value.value, w); try w.endObject(); } // write a node. returns true if children must be written. fn writeNode(self: *const Writer, id: u32, axn: AXNode, w: anytype) !bool { // ignore empty texts try w.beginObject(); try w.objectField("nodeId"); try w.write(id); try w.objectField("backendDOMNodeId"); try w.write(id); try w.objectField("role"); try self.writeAXValue(.{ .role = try axn.getRole() }, w); const ignore = axn.isIgnore(self.page); try w.objectField("ignored"); try w.write(ignore); if (ignore) { // Ignore reasons try w.objectField("ignoredReasons"); try w.beginArray(); try w.beginObject(); try w.objectField("name"); try w.write("uninteresting"); try w.objectField("value"); try self.writeAXValue(.{ .boolean = true }, w); try w.endObject(); try w.endArray(); } else { // Name try w.objectField("name"); try w.beginObject(); try w.objectField("type"); try w.write(@tagName(.computedString)); try w.objectField("value"); const source = try axn.writeName(w, self.page); if (source) |s| { try self.writeAXSource(s, w); } try w.endObject(); // Value (for form controls) try self.writeNodeValue(axn, w); // Properties try w.objectField("properties"); try w.beginArray(); try self.writeAXProperties(axn, w); try w.endArray(); } const n = axn.dom; // Parent if (n._parent) |p| { const parent_node = try self.registry.register(p); try w.objectField("parentId"); try w.write(parent_node.id); } // Children const write_children = axn.ignoreChildren() == false; const skip_text = ignoreText(axn.dom); try w.objectField("childIds"); try w.beginArray(); if (write_children) { var registry = self.registry; var it = n.childrenIterator(); while (it.next()) |child| { // ignore non-elements or text. if (child.is(DOMNode.Element.Html) == null and (child.is(DOMNode.CData.Text) == null or skip_text)) { continue; } const child_node = try registry.register(child); try w.write(child_node.id); } } try w.endArray(); try w.endObject(); return write_children; } fn writeNodeValue(self: *const Writer, axnode: AXNode, w: anytype) !void { const node = axnode.dom; if (node.is(DOMNode.Element.Html) == null) { return; } const el = node.as(DOMNode.Element); const value: ?[]const u8 = switch (el.getTag()) { .input => blk: { const input = el.as(DOMNode.Element.Html.Input); const val = input.getValue(); if (val.len == 0) break :blk null; break :blk val; }, .textarea => blk: { const textarea = el.as(DOMNode.Element.Html.TextArea); const val = textarea.getValue(); if (val.len == 0) break :blk null; break :blk val; }, .select => blk: { const select = el.as(DOMNode.Element.Html.Select); const val = select.getValue(self.page); if (val.len == 0) break :blk null; break :blk val; }, else => null, }; if (value) |val| { try w.objectField("value"); try self.writeAXValue(.{ .string = val }, w); } } }; pub const AXRole = enum(u8) { // zig fmt: off none, article, banner, blockquote, button, caption, cell, checkbox, code, color, columnheader, combobox, complementary, contentinfo, date, definition, deletion, dialog, document, emphasis, figure, file, form, group, heading, image, insertion, link, list, listbox, listitem, main, marquee, menuitem, meter, month, navigation, option, paragraph, presentation, progressbar, radio, region, row, rowgroup, rowheader, searchbox, separator, slider, spinbutton, status, strong, subscript, superscript, @"switch", table, term, textbox, time, RootWebArea, LineBreak, StaticText, // zig fmt: on fn fromNode(node: *DOMNode) !AXRole { return switch (node._type) { .document => return .RootWebArea, // Chrome specific. .cdata => |cd| { if (cd.is(DOMNode.CData.Text) == null) { log.debug(.cdp, "invalid tag", .{ .tag = cd }); return error.InvalidTag; } return .StaticText; }, .element => |el| switch (el.getTag()) { // Navigation & Structure .nav => .navigation, .main => .main, .aside => .complementary, // TODO conditions: // .banner Not descendant of article, aside, main, nav, section // (none) When descendant of article, aside, main, nav, section .header => .banner, // TODO conditions: // contentinfo Not descendant of article, aside, main, nav, section // (none) When descendant of article, aside, main, nav, section .footer => .contentinfo, // TODO conditions: // region Has accessible name (aria-label, aria-labelledby, or title) | // (none) No accessible name | .section => .region, .article, .hgroup => .article, .address => .group, // Headings .h1, .h2, .h3, .h4, .h5, .h6 => .heading, .ul, .ol, .menu => .list, .li => .listitem, .dt => .term, .dd => .definition, // Forms & Inputs // TODO conditions: // form Has accessible name // (none) No accessible name .form => .form, .input => { const input = el.as(DOMNode.Element.Html.Input); return switch (input._input_type) { .tel, .url, .email, .text => .textbox, .image, .reset, .button, .submit => .button, .radio => .radio, .range => .slider, .number => .spinbutton, .search => .searchbox, .checkbox => .checkbox, .color => .color, .date => .date, .file => .file, .month => .month, .@"datetime-local", .week, .time => .combobox, // zig fmt: off .password, .hidden => .none, // zig fmt: on }; }, .textarea => .textbox, .select => { if (el.getAttributeSafe(comptime .wrap("multiple")) != null) { return .listbox; } if (el.getAttributeSafe(comptime .wrap("size"))) |size| { if (!std.ascii.eqlIgnoreCase(size, "1")) { return .listbox; } } return .combobox; }, .option => .option, .optgroup, .fieldset => .group, .button => .button, .output => .status, .progress => .progressbar, .meter => .meter, .datalist => .listbox, // Interactive Elements .anchor, .area => { if (el.getAttributeSafe(comptime .wrap("href")) == null) { return .none; } return .link; }, .details => .group, .summary => .button, .dialog => .dialog, // Media .img => .image, .figure => .figure, // Tables .table => .table, .caption => .caption, .thead, .tbody, .tfoot => .rowgroup, .tr => .row, .th => { if (el.getAttributeSafe(comptime .wrap("scope"))) |scope| { if (std.ascii.eqlIgnoreCase(scope, "row")) { return .rowheader; } } return .columnheader; }, .td => .cell, // Text & Semantics .p => .paragraph, .hr => .separator, .blockquote => .blockquote, .code => .code, .em => .emphasis, .strong => .strong, .s, .del => .deletion, .ins => .insertion, .sub => .subscript, .sup => .superscript, .time => .time, .dfn => .term, // Document Structure .html => .none, .body => .none, // Deprecated/Obsolete Elements .marquee => .marquee, .br => .LineBreak, else => .none, }, else => |tag| { log.debug(.cdp, "invalid tag", .{ .tag = tag }); return error.InvalidTag; }, }; } }; dom: *DOMNode, role_attr: ?[]const u8, pub fn fromNode(dom: *DOMNode) AXNode { return .{ .dom = dom, .role_attr = blk: { if (dom.is(DOMNode.Element.Html) == null) { break :blk null; } const elt = dom.as(DOMNode.Element); break :blk elt.getAttributeSafe(comptime .wrap("role")); }, }; } const AXSource = enum(u8) { aria_labelledby, aria_label, label_element, // <label for="..."> label_wrap, // <label><input></label> alt, // img alt attribute title, // title attribute placeholder, // input placeholder contents, // text content value, // input value }; pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]const u8 { var aw: std.Io.Writer.Allocating = .init(allocator); defer aw.deinit(); // writeName expects a std.json.Stringify instance. const TextCaptureWriter = struct { aw: *std.Io.Writer.Allocating, writer: *std.Io.Writer, pub fn write(w: @This(), val: anytype) !void { const T = @TypeOf(val); if (T == []const u8 or T == [:0]const u8 or T == *const [val.len]u8) { try w.aw.writer.writeAll(val); } else if (comptime std.meta.hasMethod(T, "format")) { try std.fmt.format(w.aw.writer, "{s}", .{val}); } else { // Ignore unexpected types (e.g. booleans) to avoid garbage output } } // Mock JSON Stringifier lifecycle methods pub fn beginWriteRaw(_: @This()) !void {} pub fn endWriteRaw(_: @This()) void {} }; const w: TextCaptureWriter = .{ .aw = &aw, .writer = &aw.writer }; const source = try self.writeName(w, page); if (source != null) { // Remove literal quotes inserted by writeString. var raw_text = std.mem.trim(u8, aw.written(), "\""); raw_text = std.mem.trim(u8, raw_text, &std.ascii.whitespace); return try allocator.dupe(u8, raw_text); } return null; } fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource { const node = axnode.dom; return switch (node._type) { .document => |doc| switch (doc._type) { .html => |doc_html| { try w.write(try doc_html.getTitle(page)); return .title; }, else => null, }, .cdata => |cd| switch (cd._type) { .text => |*text| { try writeString(text.getWholeText(), w); return .contents; }, else => null, }, .element => |el| { // Handle aria-labelledby attribute (highest priority) if (el.getAttributeSafe(.wrap("aria-labelledby"))) |labelledby| { // Get the document to look up elements by ID const doc = node.ownerDocument(page) orelse return null; // Parse space-separated list of IDs and concatenate their text content var it = std.mem.splitScalar(u8, labelledby, ' '); var has_content = false; var buf = std.Io.Writer.Allocating.init(page.call_arena); while (it.next()) |id| { const trimmed_id = std.mem.trim(u8, id, &std.ascii.whitespace); if (trimmed_id.len == 0) continue; if (doc.getElementById(trimmed_id, page)) |referenced_el| { // Get the text content of the referenced element try referenced_el.getInnerText(&buf.writer); try buf.writer.writeByte(' '); has_content = true; } } if (has_content) { try writeString(buf.written(), w); return .aria_labelledby; } } if (el.getAttributeSafe(comptime .wrap("aria-label"))) |aria_label| { try w.write(aria_label); return .aria_label; } if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| { try w.write(alt); return .alt; } switch (el.getTag()) { .br => { try writeString("\n", w); return .contents; }, .input => { const input = el.as(DOMNode.Element.Html.Input); switch (input._input_type) { .reset, .button, .submit => |t| { const v = input.getValue(); if (v.len > 0) { try w.write(input.getValue()); } else { try w.write(@tagName(t)); } return .value; }, else => {}, } // TODO Check for <label> with matching "for" attribute // TODO Check if input is wrapped in a <label> }, // zig fmt: off .textarea, .select, .img, .audio, .video, .iframe, .embed, .object, .progress, .meter, .main, .nav, .aside, .header, .footer, .form, .section, .article, .ul, .ol, .dl, .menu, .thead, .tbody, .tfoot, .tr, .td, .div, .span, .p, .details, .li, .style, .script, .html, .body, // zig fmt: on => {}, else => { // write text content if exists. var buf: std.Io.Writer.Allocating = .init(page.call_arena); try writeAccessibleNameFallback(node, &buf.writer, page); if (buf.written().len > 0) { try writeString(buf.written(), w); return .contents; } }, } if (el.getAttributeSafe(comptime .wrap("title"))) |title| { try w.write(title); return .title; } if (el.getAttributeSafe(comptime .wrap("placeholder"))) |placeholder| { try w.write(placeholder); return .placeholder; } try w.write(""); return null; }, else => { try w.write(""); return null; }, }; } fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, page: *Page) !void { var it = node.childrenIterator(); while (it.next()) |child| { switch (child._type) { .cdata => |cd| switch (cd._type) { .text => |*text| { const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace); if (content.len > 0) { try writer.writeAll(content); try writer.writeByte(' '); } }, else => {}, }, .element => |el| { if (el.getTag() == .img) { if (el.getAttributeSafe(.wrap("alt"))) |alt| { try writer.writeAll(alt); try writer.writeByte(' '); } } else if (el.getTag() == .svg) { // Try to find a <title> inside SVG var sit = child.childrenIterator(); while (sit.next()) |s_child| { if (s_child.is(DOMNode.Element)) |s_el| { if (std.mem.eql(u8, s_el.getTagNameLower(), "title")) { try writeAccessibleNameFallback(s_child, writer, page); try writer.writeByte(' '); } } } } else { if (!el.getTag().isMetadata()) { try writeAccessibleNameFallback(child, writer, page); } } }, else => {}, } } } fn isHidden(elt: *DOMNode.Element) bool { if (elt.getAttributeSafe(comptime .wrap("aria-hidden"))) |value| { if (std.mem.eql(u8, value, "true")) { return true; } } if (elt.hasAttributeSafe(comptime .wrap("hidden"))) { return true; } if (elt.hasAttributeSafe(comptime .wrap("inert"))) { return true; } // TODO Check if aria-hidden ancestor exists // TODO Check CSS visibility (if you have access to computed styles) return false; } fn ignoreText(node: *DOMNode) bool { if (node.is(DOMNode.Element.Html) == null) { return true; } const elt = node.as(DOMNode.Element); // Only ignore text for structural/container elements that typically // don't have meaningful direct text content return switch (elt.getTag()) { // zig fmt: off // Structural containers .html, .body, .head, // Lists (text is in li elements, not in ul/ol) .ul, .ol, .menu, // Tables (text is in cells, not in table/tbody/thead/tfoot/tr) .table, .thead, .tbody, .tfoot, .tr, // Form containers .form, .fieldset, .datalist, // Grouping elements .details, .figure, // Other containers .select, .optgroup, .colgroup, .script, => true, // zig fmt: on // All other elements should include their text content else => false, }; } fn ignoreChildren(self: AXNode) bool { const node = self.dom; if (node.is(DOMNode.Element.Html) == null) { return false; } const elt = node.as(DOMNode.Element); return switch (elt.getTag()) { .head, .script, .style => true, else => false, }; } fn isIgnore(self: AXNode, page: *Page) bool { const node = self.dom; const role_attr = self.role_attr; // Don't ignore non-Element node: CData, Document... const elt = node.is(DOMNode.Element) orelse return false; // Ignore non-HTML elements: svg... if (elt._type != .html) { return true; } const tag = elt.getTag(); switch (tag) { // zig fmt: off .script, .style, .meta, .link, .title, .base, .head, .noscript, .template, .param, .source, .track, .datalist, .col, .colgroup, .html, .body => return true, // zig fmt: on .img => { // Check for empty decorative images const alt_ = elt.getAttributeSafe(comptime .wrap("alt")); if (alt_ == null or alt_.?.len == 0) { return true; } }, .input => { // Check for hidden inputs const input = elt.as(DOMNode.Element.Html.Input); if (input._input_type == .hidden) { return true; } }, else => {}, } if (role_attr) |role| { if (std.ascii.eqlIgnoreCase(role, "none") or std.ascii.eqlIgnoreCase(role, "presentation")) { return true; } } if (isHidden(elt)) { return true; } // Generic containers with no semantic value if (tag == .div or tag == .span) { const has_role = elt.hasAttributeSafe(comptime .wrap("role")); const has_aria_label = elt.hasAttributeSafe(comptime .wrap("aria-label")); const has_aria_labelledby = elt.hasAttributeSafe(.wrap("aria-labelledby")); if (!has_role and !has_aria_label and !has_aria_labelledby) { // Check if it has any non-ignored children var it = node.childrenIterator(); while (it.next()) |child| { const axn = AXNode.fromNode(child); if (!axn.isIgnore(page)) { return false; } } return true; } } return false; } pub fn getRole(self: AXNode) ![]const u8 { if (self.role_attr) |role_value| { // TODO the role can have multiple comma separated values. return role_value; } const role_implicit = try AXRole.fromNode(self.dom); return @tagName(role_implicit); } // Replace successives whitespaces with one withespace. // Trims left and right according to the options. // Returns true if the string ends with a trimmed whitespace. fn writeString(s: []const u8, w: anytype) !void { try w.beginWriteRaw(); try w.writer.writeByte('\"'); try stripWhitespaces(s, w.writer); try w.writer.writeByte('\"'); w.endWriteRaw(); } // string written is json encoded. fn stripWhitespaces(s: []const u8, writer: anytype) !void { var start: usize = 0; var prev_w: ?bool = null; var is_w: bool = undefined; for (s, 0..) |c, i| { is_w = std.ascii.isWhitespace(c); // Detect the first char type. if (prev_w == null) { prev_w = is_w; } // The current char is the same kind of char, the chunk continues. if (prev_w.? == is_w) { continue; } // Starting here, the chunk changed. if (is_w) { // We have a chunk of non-whitespaces, we write it as it. try jsonStringify.encodeJsonStringChars(s[start..i], .{}, writer); } else { // We have a chunk of whitespaces, replace with one space, // depending the position. if (start > 0) { try writer.writeByte(' '); } } // Start the new chunk. prev_w = is_w; start = i; } // Write the reminder chunk. if (!is_w) { // last chunk is non whitespaces. try jsonStringify.encodeJsonStringChars(s[start..], .{}, writer); } } test "AXnode: stripWhitespaces" { const allocator = std.testing.allocator; const TestCase = struct { value: []const u8, expected: []const u8, }; const test_cases = [_]TestCase{ .{ .value = " ", .expected = "" }, .{ .value = " ", .expected = "" }, .{ .value = "foo bar", .expected = "foo bar" }, .{ .value = "foo bar", .expected = "foo bar" }, .{ .value = " foo bar", .expected = "foo bar" }, .{ .value = "foo bar ", .expected = "foo bar" }, .{ .value = " foo bar ", .expected = "foo bar" }, .{ .value = "foo\n\tbar", .expected = "foo bar" }, .{ .value = "\tfoo bar baz \t\n yeah\r\n", .expected = "foo bar baz yeah" }, // string must be json encoded. .{ .value = "\"foo\"", .expected = "\\\"foo\\\"" }, }; var buffer = std.io.Writer.Allocating.init(allocator); defer buffer.deinit(); for (test_cases) |test_case| { buffer.clearRetainingCapacity(); try stripWhitespaces(test_case.value, &buffer.writer); try std.testing.expectEqualStrings(test_case.expected, buffer.written()); } } const testing = @import("testing.zig"); test "AXNode: writer" { var registry = Node.Registry.init(testing.allocator); defer registry.deinit(); var page = try testing.pageTest("cdp/dom3.html"); defer page._session.removePage(); var doc = page.window._document; const node = try registry.register(doc.asNode()); const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ .root = node, .registry = ®istry, .page = page, }, .{}); defer testing.allocator.free(json); // Check that the document node is present with proper structure const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); defer parsed.deinit(); const nodes = parsed.value.array.items; try testing.expect(nodes.len > 0); // First node should be the document const doc_node = nodes[0].object; try testing.expectEqual(1, doc_node.get("nodeId").?.integer); try testing.expectEqual(1, doc_node.get("backendDOMNodeId").?.integer); try testing.expectEqual(false, doc_node.get("ignored").?.bool); const role = doc_node.get("role").?.object; try testing.expectEqual("role", role.get("type").?.string); try testing.expectEqual("RootWebArea", role.get("value").?.string); const name = doc_node.get("name").?.object; try testing.expectEqual("computedString", name.get("type").?.string); try testing.expectEqual("Test Page", name.get("value").?.string); // Check properties array exists const properties = doc_node.get("properties").?.array.items; try testing.expect(properties.len >= 1); // Check childIds array exists const child_ids = doc_node.get("childIds").?.array.items; try testing.expect(child_ids.len > 0); // Find the h1 node and verify its level property is serialized as a string for (nodes) |node_val| { const obj = node_val.object; const role_obj = obj.get("role") orelse continue; const role_val = role_obj.object.get("value") orelse continue; if (!std.mem.eql(u8, role_val.string, "heading")) continue; const props = obj.get("properties").?.array.items; for (props) |prop| { const prop_obj = prop.object; const name_str = prop_obj.get("name").?.string; if (!std.mem.eql(u8, name_str, "level")) continue; const level_value = prop_obj.get("value").?.object; try testing.expectEqual("integer", level_value.get("type").?.string); // CDP spec: integer values must be serialized as strings try testing.expectEqual("1", level_value.get("value").?.string); return; } } return error.HeadingNodeNotFound; } ================================================ FILE: src/cdp/Node.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Allocator = std.mem.Allocator; const log = @import("../log.zig"); const Page = @import("../browser/Page.zig"); const DOMNode = @import("../browser/webapi/Node.zig"); pub const Id = u32; const Node = @This(); id: Id, dom: *DOMNode, set_child_nodes_event: bool, // Whenever we send a node to the client, we register it here for future lookup. // We maintain a node -> id and id -> node lookup. pub const Registry = struct { node_id: u32, allocator: Allocator, arena: std.heap.ArenaAllocator, node_pool: std.heap.MemoryPool(Node), lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node), lookup_by_node: std.HashMapUnmanaged(*DOMNode, *Node, NodeContext, std.hash_map.default_max_load_percentage), pub fn init(allocator: Allocator) Registry { return .{ .node_id = 1, .lookup_by_id = .{}, .lookup_by_node = .{}, .allocator = allocator, .arena = std.heap.ArenaAllocator.init(allocator), .node_pool = std.heap.MemoryPool(Node).init(allocator), }; } pub fn deinit(self: *Registry) void { const allocator = self.allocator; self.lookup_by_id.deinit(allocator); self.lookup_by_node.deinit(allocator); self.node_pool.deinit(); self.arena.deinit(); } pub fn reset(self: *Registry) void { self.lookup_by_id.clearRetainingCapacity(); self.lookup_by_node.clearRetainingCapacity(); _ = self.arena.reset(.{ .retain_with_limit = 1024 }); _ = self.node_pool.reset(.{ .retain_with_limit = 1024 }); } pub fn register(self: *Registry, dom_node: *DOMNode) !*Node { const node_lookup_gop = try self.lookup_by_node.getOrPut(self.allocator, dom_node); if (node_lookup_gop.found_existing) { return node_lookup_gop.value_ptr.*; } // on error, we're probably going to abort the entire browser context // but, just in case, let's try to keep things tidy. errdefer _ = self.lookup_by_node.remove(dom_node); const node = try self.node_pool.create(); errdefer self.node_pool.destroy(node); const id = self.node_id; self.node_id = id + 1; node.* = .{ .id = id, .dom = dom_node, .set_child_nodes_event = false, }; node_lookup_gop.value_ptr.* = node; try self.lookup_by_id.putNoClobber(self.allocator, id, node); return node; } }; const NodeContext = struct { pub fn hash(_: NodeContext, dom_node: *DOMNode) u64 { return std.hash.Wyhash.hash(0, std.mem.asBytes(&@intFromPtr(dom_node))); } pub fn eql(_: NodeContext, a: *DOMNode, b: *DOMNode) bool { return @intFromPtr(a) == @intFromPtr(b); } }; // Searches are a 3 step process: // 1 - Dom.performSearch // 2 - Dom.getSearchResults // 3 - Dom.discardSearchResults // // For a given browser context, we can have multiple active searches. I.e. // performSearch could be called multiple times without getSearchResults or // discardSearchResults being called. We keep these active searches in the // browser context's node_search_list, which is a SearchList. Since we don't // expect many active searches (mostly just 1), a list is fine to scan through. pub const Search = struct { name: []const u8, node_ids: []const Id, pub const List = struct { search_id: u16 = 0, registry: *Registry, arena: std.heap.ArenaAllocator, searches: std.ArrayList(Search) = .{}, pub fn init(allocator: Allocator, registry: *Registry) List { return .{ .registry = registry, .arena = std.heap.ArenaAllocator.init(allocator), }; } pub fn deinit(self: *List) void { self.arena.deinit(); } pub fn reset(self: *List) void { self.search_id = 0; self.searches = .{}; _ = self.arena.reset(.{ .retain_with_limit = 4096 }); } pub fn create(self: *List, nodes: []const *DOMNode) !Search { const id = self.search_id; defer self.search_id = id +% 1; const arena = self.arena.allocator(); const name = switch (id) { 0 => "0", 1 => "1", 2 => "2", 3 => "3", 4 => "4", 5 => "5", 6 => "6", 7 => "7", 8 => "8", 9 => "9", else => try std.fmt.allocPrint(arena, "{d}", .{id}), }; var registry = self.registry; const node_ids = try arena.alloc(Id, nodes.len); for (nodes, node_ids) |node, *node_id| { node_id.* = (try registry.register(node)).id; } const search = Search{ .name = name, .node_ids = node_ids, }; try self.searches.append(arena, search); return search; } pub fn remove(self: *List, name: []const u8) void { for (self.searches.items, 0..) |search, i| { if (std.mem.eql(u8, name, search.name)) { _ = self.searches.swapRemove(i); return; } } } pub fn get(self: *const List, name: []const u8) ?Search { for (self.searches.items) |search| { if (std.mem.eql(u8, name, search.name)) { return search; } } return null; } }; }; // Need a custom writer, because we can't just serialize the node as-is. // Sometimes we want to serializ the node without chidren, sometimes with just // its direct children, and sometimes the entire tree. // (For now, we only support direct children) pub const Writer = struct { depth: i32, exclude_root: bool, root: *const Node, registry: *Registry, pub const Opts = struct { depth: i32 = 0, exclude_root: bool = false, }; pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { if (self.exclude_root) { _ = self.writeChildren(self.root, 1, w) catch |err| { log.err(.cdp, "node writeChildren", .{ .err = err }); return error.WriteFailed; }; } else { self.toJSON(self.root, 0, w) catch |err| { // The only error our jsonStringify method can return is // @TypeOf(w).Error. In other words, our code can't return its own // error, we can only return a writer error. Kinda sucks. log.err(.cdp, "node toJSON stringify", .{ .err = err }); return error.WriteFailed; }; } } fn toJSON(self: *const Writer, node: *const Node, depth: usize, w: anytype) !void { try w.beginObject(); try self.writeCommon(node, false, w); try w.objectField("children"); const child_count = try self.writeChildren(node, depth, w); try w.objectField("childNodeCount"); try w.write(child_count); try w.endObject(); } fn writeChildren(self: *const Writer, node: *const Node, depth: usize, w: anytype) anyerror!usize { var count: usize = 0; var it = node.dom.childrenIterator(); var registry = self.registry; const full_child = self.depth < 0 or self.depth < depth; try w.beginArray(); while (it.next()) |dom_child| { const child_node = try registry.register(dom_child); if (full_child) { try self.toJSON(child_node, depth + 1, w); } else { try w.beginObject(); try self.writeCommon(child_node, true, w); try w.endObject(); } count += 1; } try w.endArray(); return count; } fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void { try w.objectField("nodeId"); try w.write(node.id); try w.objectField("backendNodeId"); try w.write(node.id); const dom_node = node.dom; if (dom_node._parent) |dom_parent| { const parent_node = try self.registry.register(dom_parent); try w.objectField("parentId"); try w.write(parent_node.id); } if (dom_node.is(DOMNode.Element)) |element| { if (element.hasAttributes()) { try w.objectField("attributes"); try w.beginArray(); var it = element.attributeIterator(); while (it.next()) |attr| { try w.write(attr._name.str()); try w.write(attr._value.str()); } try w.endArray(); } try w.objectField("localName"); try w.write(element.getLocalName()); } else { try w.objectField("localName"); try w.write(""); } try w.objectField("nodeType"); try w.write(dom_node.getNodeType()); try w.objectField("nodeName"); var name_buf: [Page.BUF_SIZE]u8 = undefined; try w.write(dom_node.getNodeName(&name_buf)); try w.objectField("nodeValue"); if (dom_node.getNodeValue()) |nv| { try w.write(nv.str()); } else { try w.write(""); } if (include_child_count) { try w.objectField("childNodeCount"); try w.write(dom_node.getChildrenCount()); } try w.objectField("documentURL"); try w.write(null); try w.objectField("baseURL"); try w.write(null); try w.objectField("xmlVersion"); try w.write(""); try w.objectField("compatibilityMode"); try w.write("NoQuirksMode"); try w.objectField("isScrollable"); try w.write(false); } }; const testing = @import("testing.zig"); test "cdp Node: Registry register" { var registry = Registry.init(testing.allocator); defer registry.deinit(); try testing.expectEqual(0, registry.lookup_by_id.count()); try testing.expectEqual(0, registry.lookup_by_node.count()); var page = try testing.pageTest("cdp/registry1.html"); defer page._session.removePage(); var doc = page.window._document; { const dom_node = (try doc.querySelector(.wrap("#a1"), page)).?.asNode(); const node = try registry.register(dom_node); const n1b = registry.lookup_by_id.get(1).?; const n1c = registry.lookup_by_node.get(node.dom).?; try testing.expectEqual(node, n1b); try testing.expectEqual(node, n1c); try testing.expectEqual(1, node.id); try testing.expectEqual(dom_node, node.dom); } { const dom_node = (try doc.querySelector(.wrap("p"), page)).?.asNode(); const node = try registry.register(dom_node); const n1b = registry.lookup_by_id.get(2).?; const n1c = registry.lookup_by_node.get(node.dom).?; try testing.expectEqual(node, n1b); try testing.expectEqual(node, n1c); try testing.expectEqual(2, node.id); try testing.expectEqual(dom_node, node.dom); } } test "cdp Node: search list" { var registry = Registry.init(testing.allocator); defer registry.deinit(); var search_list = Search.List.init(testing.allocator, ®istry); defer search_list.deinit(); { // empty search list, noops search_list.remove("0"); try testing.expectEqual(null, search_list.get("0")); } { // empty nodes const s1 = try search_list.create(&.{}); try testing.expectEqual("0", s1.name); try testing.expectEqual(0, s1.node_ids.len); const s2 = search_list.get("0").?; try testing.expectEqual("0", s2.name); try testing.expectEqual(0, s2.node_ids.len); search_list.remove("0"); try testing.expectEqual(null, search_list.get("0")); } { var page = try testing.pageTest("cdp/registry2.html"); defer page._session.removePage(); var doc = page.window._document; { const l1 = try doc.querySelectorAll(.wrap("a"), page); defer l1.deinit(page._session); const s1 = try search_list.create(l1._nodes); try testing.expectEqual("1", s1.name); try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); } try testing.expectEqual(2, registry.lookup_by_id.count()); try testing.expectEqual(2, registry.lookup_by_node.count()); { const l2 = try doc.querySelectorAll(.wrap("#a1"), page); defer l2.deinit(page._session); const s2 = try search_list.create(l2._nodes); try testing.expectEqual("2", s2.name); try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); } { const l3 = try doc.querySelectorAll(.wrap("#a2"), page); defer l3.deinit(page._session); const s3 = try search_list.create(l3._nodes); try testing.expectEqual("3", s3.name); try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); } try testing.expectEqual(2, registry.lookup_by_id.count()); try testing.expectEqual(2, registry.lookup_by_node.count()); } } test "cdp Node: Writer" { var registry = Registry.init(testing.allocator); defer registry.deinit(); var page = try testing.pageTest("cdp/registry3.html"); defer page._session.removePage(); var doc = page.window._document; { const node = try registry.register(doc.asNode()); const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ .root = node, .depth = 0, .exclude_root = false, .registry = ®istry, }, .{}); defer testing.allocator.free(json); try testing.expectJson(.{ .nodeId = 1, .backendNodeId = 1, .nodeType = 9, .nodeName = "#document", .localName = "", .nodeValue = "", .documentURL = null, .baseURL = null, .xmlVersion = "", .isScrollable = false, .compatibilityMode = "NoQuirksMode", .childNodeCount = 1, .children = &.{.{ .nodeId = 2, .backendNodeId = 2, .nodeType = 1, .nodeName = "HTML", .localName = "html", .nodeValue = "", .childNodeCount = 2, .documentURL = null, .baseURL = null, .xmlVersion = "", .compatibilityMode = "NoQuirksMode", .isScrollable = false, }}, }, json); } { const node = registry.lookup_by_id.get(2).?; const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ .root = node, .depth = 1, .exclude_root = false, .registry = ®istry, }, .{}); defer testing.allocator.free(json); try testing.expectJson(.{ .nodeId = 2, .backendNodeId = 2, .nodeType = 1, .nodeName = "HTML", .localName = "html", .nodeValue = "", .childNodeCount = 2, .documentURL = null, .baseURL = null, .xmlVersion = "", .compatibilityMode = "NoQuirksMode", .isScrollable = false, .children = &.{ .{ .nodeId = 3, .backendNodeId = 3, .nodeType = 1, .nodeName = "HEAD", .localName = "head", .nodeValue = "", .childNodeCount = 0, .documentURL = null, .baseURL = null, .xmlVersion = "", .compatibilityMode = "NoQuirksMode", .isScrollable = false, .parentId = 2, }, .{ .nodeId = 4, .backendNodeId = 4, .nodeType = 1, .nodeName = "BODY", .localName = "body", .nodeValue = "", .childNodeCount = 3, .documentURL = null, .baseURL = null, .xmlVersion = "", .compatibilityMode = "NoQuirksMode", .isScrollable = false, .parentId = 2, } }, }, json); } { const node = registry.lookup_by_id.get(2).?; const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{ .root = node, .depth = -1, .exclude_root = true, .registry = ®istry, }, .{}); defer testing.allocator.free(json); try testing.expectJson(&.{ .{ .nodeId = 3, .backendNodeId = 3, .nodeType = 1, .nodeName = "HEAD", .localName = "head", .nodeValue = "", .childNodeCount = 0, .documentURL = null, .baseURL = null, .xmlVersion = "", .compatibilityMode = "NoQuirksMode", .isScrollable = false, .parentId = 2, }, .{ .nodeId = 4, .backendNodeId = 4, .nodeType = 1, .nodeName = "BODY", .localName = "body", .nodeValue = "", .childNodeCount = 3, .documentURL = null, .baseURL = null, .xmlVersion = "", .compatibilityMode = "NoQuirksMode", .isScrollable = false, .children = &.{ .{ .nodeId = 5, .localName = "a", .childNodeCount = 0, .attributes = &.{ "id", "a1" }, .parentId = 4, }, .{ .nodeId = 6, .localName = "div", .childNodeCount = 1, .parentId = 4, .children = &.{.{ .nodeId = 7, .localName = "a", .childNodeCount = 0, .parentId = 6, .attributes = &.{ "id", "a2" }, }}, }, .{ .nodeId = 8, .backendNodeId = 8, .nodeName = "#text", .localName = "", .childNodeCount = 0, .parentId = 4, .nodeValue = "\n", } }, } }, json); } } ================================================ FILE: src/cdp/cdp.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const Allocator = std.mem.Allocator; const json = std.json; const log = @import("../log.zig"); const js = @import("../browser/js/js.zig"); const App = @import("../App.zig"); const Browser = @import("../browser/Browser.zig"); const Session = @import("../browser/Session.zig"); const HttpClient = @import("../browser/HttpClient.zig"); const Page = @import("../browser/Page.zig"); const Incrementing = @import("id.zig").Incrementing; const Notification = @import("../Notification.zig"); const InterceptState = @import("domains/fetch.zig").InterceptState; pub const URL_BASE = "chrome://newtab/"; const IS_DEBUG = @import("builtin").mode == .Debug; pub const CDP = CDPT(struct { const Client = *@import("../Server.zig").Client; }); const SessionIdGen = Incrementing(u32, "SID"); const TargetIdGen = Incrementing(u32, "TID"); const BrowserContextIdGen = Incrementing(u32, "BID"); // Generic so that we can inject mocks into it. pub fn CDPT(comptime TypeProvider: type) type { return struct { // Used for sending message to the client and closing on error client: TypeProvider.Client, allocator: Allocator, // The active browser browser: Browser, // when true, any target creation must be attached. target_auto_attach: bool = false, target_id_gen: TargetIdGen = .{}, session_id_gen: SessionIdGen = .{}, browser_context_id_gen: BrowserContextIdGen = .{}, browser_context: ?BrowserContext(Self), // Re-used arena for processing a message. We're assuming that we're getting // 1 message at a time. message_arena: std.heap.ArenaAllocator, // Used for processing notifications within a browser context. notification_arena: std.heap.ArenaAllocator, // Valid for 1 page navigation (what CDP calls a "renderer") page_arena: std.heap.ArenaAllocator, // Valid for the entire lifetime of the BrowserContext. Should minimize // (or altogether elimiate) our use of this. browser_context_arena: std.heap.ArenaAllocator, const Self = @This(); pub fn init(app: *App, http_client: *HttpClient, client: TypeProvider.Client) !Self { const allocator = app.allocator; const browser = try Browser.init(app, .{ .env = .{ .with_inspector = true }, .http_client = http_client, }); errdefer browser.deinit(); return .{ .client = client, .browser = browser, .allocator = allocator, .browser_context = null, .page_arena = std.heap.ArenaAllocator.init(allocator), .message_arena = std.heap.ArenaAllocator.init(allocator), .notification_arena = std.heap.ArenaAllocator.init(allocator), .browser_context_arena = std.heap.ArenaAllocator.init(allocator), }; } pub fn deinit(self: *Self) void { if (self.browser_context) |*bc| { bc.deinit(); } self.browser.deinit(); self.page_arena.deinit(); self.message_arena.deinit(); self.notification_arena.deinit(); self.browser_context_arena.deinit(); } pub fn handleMessage(self: *Self, msg: []const u8) bool { // if there's an error, it's already been logged self.processMessage(msg) catch return false; return true; } pub fn processMessage(self: *Self, msg: []const u8) !void { const arena = &self.message_arena; defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 }); return self.dispatch(arena.allocator(), self, msg); } // @newhttp // A bit hacky right now. The main server loop doesn't unblock for // scheduled task. So we run this directly in order to process any // timeouts (or http events) which are ready to be processed. pub fn pageWait(self: *Self, ms: u32) Session.WaitResult { const session = &(self.browser.session orelse return .no_page); return session.wait(ms); } // Called from above, in processMessage which handles client messages // but can also be called internally. For example, Target.sendMessageToTarget // calls back into dispatch to capture the response. pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void { const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{ .ignore_unknown_fields = true, }) catch return error.InvalidJSON; var command = Command(Self, @TypeOf(sender)){ .input = .{ .json = str, .id = input.id, .action = "", .params = input.params, .session_id = input.sessionId, }, .cdp = self, .arena = arena, .sender = sender, .browser_context = if (self.browser_context) |*bc| bc else null, }; // See dispatchStartupCommand for more info on this. var is_startup = false; if (input.sessionId) |input_session_id| { if (std.mem.eql(u8, input_session_id, "STARTUP")) { is_startup = true; } else if (self.isValidSessionId(input_session_id) == false) { return command.sendError(-32001, "Unknown sessionId", .{}); } } if (is_startup) { dispatchStartupCommand(&command, input.method) catch |err| { command.sendError(-31999, @errorName(err), .{}) catch return err; }; } else { dispatchCommand(&command, input.method) catch |err| { command.sendError(-31998, @errorName(err), .{}) catch return err; }; } } // A CDP session isn't 100% fully driven by the driver. There's are // independent actions that the browser is expected to take. For example // Puppeteer expects the browser to startup a tab and thus have existing // targets. // To this end, we create a [very] dummy BrowserContext, Target and // Session. There isn't actually a BrowserContext, just a special id. // When messages are received with the "STARTUP" sessionId, we do // "special" handling - the bare minimum we need to do until the driver // switches to a real BrowserContext. // (I can imagine this logic will become driver-specific) fn dispatchStartupCommand(command: anytype, method: []const u8) !void { // Stagehand parses the response and error if we don't return a // correct one for this call. if (std.mem.eql(u8, method, "Page.getFrameTree")) { return command.sendResult(.{ .frameTree = .{ .frame = .{ .id = "TID-STARTUP", .loaderId = "LOADERID24DD2FD56CF1EF33C965C79C", .securityOrigin = URL_BASE, .url = "about:blank", .secureContextType = "Secure", }, }, }, .{}); } return command.sendResult(null, .{}); } fn dispatchCommand(command: anytype, method: []const u8) !void { const domain = blk: { const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse { return error.InvalidMethod; }; command.input.action = method[i + 1 ..]; break :blk method[0..i]; }; switch (domain.len) { 2 => switch (@as(u16, @bitCast(domain[0..2].*))) { asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command), else => {}, }, 3 => switch (@as(u24, @bitCast(domain[0..3].*))) { asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command), asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command), asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command), else => {}, }, 4 => switch (@as(u32, @bitCast(domain[0..4].*))) { asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command), else => {}, }, 5 => switch (@as(u40, @bitCast(domain[0..5].*))) { asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command), asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command), else => {}, }, 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command), else => {}, }, 7 => switch (@as(u56, @bitCast(domain[0..7].*))) { asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command), asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command), asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command), asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command), else => {}, }, 8 => switch (@as(u64, @bitCast(domain[0..8].*))) { asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command), else => {}, }, 9 => switch (@as(u72, @bitCast(domain[0..9].*))) { asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command), asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command), else => {}, }, 11 => switch (@as(u88, @bitCast(domain[0..11].*))) { asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command), else => {}, }, 13 => switch (@as(u104, @bitCast(domain[0..13].*))) { asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command), else => {}, }, else => {}, } return error.UnknownDomain; } fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool { const browser_context = &(self.browser_context orelse return false); const session_id = browser_context.session_id orelse return false; return std.mem.eql(u8, session_id, input_session_id); } pub fn createBrowserContext(self: *Self) ![]const u8 { if (self.browser_context != null) { return error.AlreadyExists; } const id = self.browser_context_id_gen.next(); self.browser_context = @as(BrowserContext(Self), undefined); const browser_context = &self.browser_context.?; try BrowserContext(Self).init(browser_context, id, self); return id; } pub fn disposeBrowserContext(self: *Self, browser_context_id: []const u8) bool { const bc = &(self.browser_context orelse return false); if (std.mem.eql(u8, bc.id, browser_context_id) == false) { return false; } bc.deinit(); self.browser.closeSession(); self.browser_context = null; return true; } const SendEventOpts = struct { session_id: ?[]const u8 = null, }; pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void { return self.sendJSON(.{ .method = method, .params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p, .sessionId = opts.session_id, }); } pub fn sendJSON(self: *Self, message: anytype) !void { return self.client.sendJSON(message, .{ .emit_null_optional_fields = false, }); } }; } pub fn BrowserContext(comptime CDP_T: type) type { const Node = @import("Node.zig"); const AXNode = @import("AXNode.zig"); return struct { id: []const u8, cdp: *CDP_T, // Represents the browser session. There is no equivalent in CDP. For // all intents and purpose, from CDP's point of view our Browser and // our Session more or less maps to a BrowserContext. THIS HAS ZERO // RELATION TO SESSION_ID session: *Session, // Tied to the lifetime of the BrowserContext arena: Allocator, // Tied to the lifetime of 1 page rendered in the BrowserContext. page_arena: Allocator, // From the parent's notification_arena.allocator(). Most of the CDP // code paths deal with a cmd which has its own arena (from the // message_arena). But notifications happen outside of the typical CDP // request->response, and thus don't have a cmd and don't have an arena. notification_arena: Allocator, // Maps to our Page. (There are other types of targets, but we only // deal with "pages" for now). Since we only allow 1 open page at a // time, we only have 1 target_id. target_id: ?[14]u8, // The CDP session_id. After the target/page is created, the client // "attaches" to it (either explicitly or automatically). We return a // "sessionId" which identifies this link. `sessionId` is the how // the CDP client informs us what it's trying to manipulate. Because we // only support 1 BrowserContext at a time, and 1 page at a time, this // is all pretty straightforward, but it still needs to be enforced, i.e. // if we get a request with a sessionId that doesn't match the current one // we should reject it. session_id: ?[]const u8, security_origin: []const u8, page_life_cycle_events: bool, secure_context_type: []const u8, node_registry: Node.Registry, node_search_list: Node.Search.List, inspector_session: *js.Inspector.Session, isolated_worlds: std.ArrayList(*IsolatedWorld), http_proxy_changed: bool = false, // Extra headers to add to all requests. extra_headers: std.ArrayList([*c]const u8) = .empty, intercept_state: InterceptState, // When network is enabled, we'll capture the transfer.id -> body // This is awfully memory intensive, but our underlying http client and // its users (script manager and page) correctly do not hold the body // memory longer than they have to. In fact, the main request is only // ever streamed. So if CDP is the only thing that needs bodies in // memory for an arbitrary amount of time, then that's where we're going // to store the, captured_responses: std.AutoHashMapUnmanaged(usize, std.ArrayList(u8)), notification: *Notification, const Self = @This(); fn init(self: *Self, id: []const u8, cdp: *CDP_T) !void { const allocator = cdp.allocator; // Create notification for this BrowserContext const notification = try Notification.init(allocator); errdefer notification.deinit(); const session = try cdp.browser.newSession(notification); const browser = &cdp.browser; const inspector_session = browser.env.inspector.?.startSession(self); errdefer browser.env.inspector.?.stopSession(); var registry = Node.Registry.init(allocator); errdefer registry.deinit(); self.* = .{ .id = id, .cdp = cdp, .target_id = null, .session_id = null, .session = session, .security_origin = URL_BASE, .secure_context_type = "Secure", // TODO = enum .page_life_cycle_events = false, // TODO; Target based value .node_registry = registry, .node_search_list = undefined, .isolated_worlds = .empty, .inspector_session = inspector_session, .page_arena = cdp.page_arena.allocator(), .arena = cdp.browser_context_arena.allocator(), .notification_arena = cdp.notification_arena.allocator(), .intercept_state = try InterceptState.init(allocator), .captured_responses = .empty, .notification = notification, }; self.node_search_list = Node.Search.List.init(allocator, &self.node_registry); errdefer self.deinit(); try notification.register(.page_remove, self, onPageRemove); try notification.register(.page_created, self, onPageCreated); try notification.register(.page_navigate, self, onPageNavigate); try notification.register(.page_navigated, self, onPageNavigated); try notification.register(.page_frame_created, self, onPageFrameCreated); } pub fn deinit(self: *Self) void { const browser = &self.cdp.browser; const env = &browser.env; // resetContextGroup detach the inspector from all contexts. // It appends async tasks, so we make sure we run the message loop // before deinit it. env.inspector.?.resetContextGroup(); env.inspector.?.stopSession(); // abort all intercepted requests before closing the sesion/page // since some of these might callback into the page/scriptmanager for (self.intercept_state.pendingTransfers()) |transfer| { transfer.abort(error.ClientDisconnect); } for (self.isolated_worlds.items) |world| { world.deinit(); } self.isolated_worlds.clearRetainingCapacity(); // do this before closeSession, since we don't want to process any // new notification (Or maybe, instead of the deinit above, we just // rely on those notifications to do our normal cleanup?) self.notification.unregisterAll(self); // If the session has a page, we need to clear it first. The page // context is always nested inside of the isolated world context, // so we need to shutdown the page one first. browser.closeSession(); self.node_registry.deinit(); self.node_search_list.deinit(); self.notification.deinit(); if (self.http_proxy_changed) { // has to be called after browser.closeSession, since it won't // work if there are active connections. browser.http_client.restoreOriginalProxy() catch |err| { log.warn(.http, "restoreOriginalProxy", .{ .err = err }); }; } self.intercept_state.deinit(); } pub fn reset(self: *Self) void { self.node_registry.reset(); self.node_search_list.reset(); } pub fn createIsolatedWorld(self: *Self, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld { const browser = &self.cdp.browser; const arena = try browser.arena_pool.acquire(); errdefer browser.arena_pool.release(arena); const world = try arena.create(IsolatedWorld); world.* = .{ .arena = arena, .context = null, .browser = browser, .name = try arena.dupe(u8, world_name), .grant_universal_access = grant_universal_access, }; try self.isolated_worlds.append(self.arena, world); return world; } pub fn nodeWriter(self: *Self, root: *const Node, opts: Node.Writer.Opts) Node.Writer { return .{ .root = root, .depth = opts.depth, .exclude_root = opts.exclude_root, .registry = &self.node_registry, }; } pub fn axnodeWriter(self: *Self, root: *const Node, opts: AXNode.Writer.Opts) !AXNode.Writer { const page = self.session.currentPage() orelse return error.PageNotLoaded; _ = opts; return .{ .page = page, .root = root, .registry = &self.node_registry, }; } pub fn getURL(self: *const Self) ?[:0]const u8 { const page = self.session.currentPage() orelse return null; const url = page.url; return if (url.len == 0) null else url; } pub fn getTitle(self: *const Self) ?[]const u8 { const page = self.session.currentPage() orelse return null; return page.getTitle() catch |err| { log.err(.cdp, "page title", .{ .err = err }); return null; }; } pub fn networkEnable(self: *Self) !void { try self.notification.register(.http_request_fail, self, onHttpRequestFail); try self.notification.register(.http_request_start, self, onHttpRequestStart); try self.notification.register(.http_request_done, self, onHttpRequestDone); try self.notification.register(.http_response_data, self, onHttpResponseData); try self.notification.register(.http_response_header_done, self, onHttpResponseHeadersDone); } pub fn networkDisable(self: *Self) void { self.notification.unregister(.http_request_fail, self); self.notification.unregister(.http_request_start, self); self.notification.unregister(.http_request_done, self); self.notification.unregister(.http_response_data, self); self.notification.unregister(.http_response_header_done, self); } pub fn fetchEnable(self: *Self, authRequests: bool) !void { try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept); if (authRequests) { try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired); } } pub fn fetchDisable(self: *Self) void { self.notification.unregister(.http_request_intercept, self); self.notification.unregister(.http_request_auth_required, self); } pub fn lifecycleEventsEnable(self: *Self) !void { self.page_life_cycle_events = true; try self.notification.register(.page_network_idle, self, onPageNetworkIdle); try self.notification.register(.page_network_almost_idle, self, onPageNetworkAlmostIdle); } pub fn lifecycleEventsDisable(self: *Self) void { self.page_life_cycle_events = false; self.notification.unregister(.page_network_idle, self); self.notification.unregister(.page_network_almost_idle, self); } pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void { const self: *Self = @ptrCast(@alignCast(ctx)); try @import("domains/page.zig").pageRemove(self); } pub fn onPageCreated(ctx: *anyopaque, page: *Page) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").pageCreated(self, page); } pub fn onPageNavigate(ctx: *anyopaque, msg: *const Notification.PageNavigate) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").pageNavigate(self, msg); } pub fn onPageNavigated(ctx: *anyopaque, msg: *const Notification.PageNavigated) !void { const self: *Self = @ptrCast(@alignCast(ctx)); defer self.resetNotificationArena(); return @import("domains/page.zig").pageNavigated(self.notification_arena, self, msg); } pub fn onPageFrameCreated(ctx: *anyopaque, msg: *const Notification.PageFrameCreated) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").pageFrameCreated(self, msg); } pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").pageNetworkIdle(self, msg); } pub fn onPageNetworkAlmostIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkAlmostIdle) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").pageNetworkAlmostIdle(self, msg); } pub fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void { const self: *Self = @ptrCast(@alignCast(ctx)); try @import("domains/network.zig").httpRequestStart(self, msg); } pub fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void { const self: *Self = @ptrCast(@alignCast(ctx)); try @import("domains/fetch.zig").requestIntercept(self, msg); } pub fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/network.zig").httpRequestFail(self, msg); } pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void { const self: *Self = @ptrCast(@alignCast(ctx)); defer self.resetNotificationArena(); return @import("domains/network.zig").httpResponseHeaderDone(self.notification_arena, self, msg); } pub fn onHttpRequestDone(ctx: *anyopaque, msg: *const Notification.RequestDone) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/network.zig").httpRequestDone(self, msg); } pub fn onHttpResponseData(ctx: *anyopaque, msg: *const Notification.ResponseData) !void { const self: *Self = @ptrCast(@alignCast(ctx)); const arena = self.page_arena; const id = msg.transfer.id; const gop = try self.captured_responses.getOrPut(arena, id); if (!gop.found_existing) { gop.value_ptr.* = .{}; } try gop.value_ptr.appendSlice(arena, try arena.dupe(u8, msg.data)); } pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void { const self: *Self = @ptrCast(@alignCast(ctx)); defer self.resetNotificationArena(); try @import("domains/fetch.zig").requestAuthRequired(self, data); } fn resetNotificationArena(self: *Self) void { defer _ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 }); } pub fn callInspector(self: *const Self, msg: []const u8) void { self.inspector_session.send(msg); self.session.browser.env.runMicrotasks(); } pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| { log.err(.cdp, "send inspector response", .{ .err = err }); }; } pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void { if (log.enabled(.cdp, .debug)) { // msg should be {"method":<method>,... lp.assert(std.mem.startsWith(u8, msg, "{\"method\":"), "onInspectorEvent prefix", .{}); const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse { log.err(.cdp, "invalid inspector event", .{ .msg = msg }); return; }; const method = msg[10..method_end]; log.debug(.cdp, "inspector event", .{ .method = method }); } sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| { log.err(.cdp, "send inspector event", .{ .err = err }); }; } // This is hacky x 2. First, we create the JSON payload by gluing our // session_id onto it. Second, we're much more client/websocket aware than // we should be. fn sendInspectorMessage(self: *Self, msg: []const u8) !void { const session_id = self.session_id orelse { // We no longer have an active session. What should we do // in this case? return; }; const cdp = self.cdp; const allocator = cdp.client.sendAllocator(); const field = ",\"sessionId\":\""; // + 1 for the closing quote after the session id // + 10 for the max websocket header const message_len = msg.len + session_id.len + 1 + field.len + 10; var buf: std.ArrayList(u8) = .{}; buf.ensureTotalCapacity(allocator, message_len) catch |err| { log.err(.cdp, "inspector buffer", .{ .err = err }); return; }; // reserve 10 bytes for websocket header buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }); // -1 because we dont' want the closing brace '}' buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]); buf.appendSliceAssumeCapacity(field); buf.appendSliceAssumeCapacity(session_id); buf.appendSliceAssumeCapacity("\"}"); if (comptime IS_DEBUG) { std.debug.assert(buf.items.len == message_len); } try cdp.client.sendJSONRaw(buf); } }; } /// see: https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world /// The current understanding. An isolated world lives in the same isolate, but a separated context. /// Clients create this to be able to create variables and run code without interfering with the /// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after /// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed /// in the isolated world by using its Context ID or the worldName. /// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects. /// An isolated world has it's own instance of globals like Window. /// Generally the client needs to resolve a node into the isolated world to be able to work with it. /// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts. const IsolatedWorld = struct { arena: Allocator, browser: *Browser, name: []const u8, context: ?*js.Context = null, grant_universal_access: bool, pub fn deinit(self: *IsolatedWorld) void { self.removeContext() catch {}; self.browser.arena_pool.release(self.arena); } pub fn removeContext(self: *IsolatedWorld) !void { const ctx = self.context orelse return error.NoIsolatedContextToRemove; self.browser.env.destroyContext(ctx); self.context = null; } // The isolate world must share at least some of the state with the related page, specifically the DocumentHTML // (assuming grantUniveralAccess will be set to True!). // We just created the world and the page. The page's state lives in the session, but is update on navigation. // This also means this pointer becomes invalid after removePage until a new page is created. // Currently we have only 1 page/frame and thus also only 1 state in the isolate world. pub fn createContext(self: *IsolatedWorld, page: *Page) !*js.Context { if (self.context == null) { self.context = try self.browser.env.createContext(page); } else { log.warn(.cdp, "not implemented", .{ .feature = "createContext: Not implemented second isolated context creation", .info = "reuse existing context", }); } return self.context.?; } }; // This is a generic because when we send a result we have two different // behaviors. Normally, we're sending the result to the client. But in some cases // we want to capture the result. So we want the command.sendResult to be // generic. pub fn Command(comptime CDP_T: type, comptime Sender: type) type { return struct { // A misc arena that can be used for any allocation for processing // the message arena: Allocator, // reference to our CDP instance cdp: *CDP_T, // The browser context this command targets browser_context: ?*BrowserContext(CDP_T), // The command input (the id, optional session_id, params, ...) input: Input, // In most cases, Sender is going to be cdp itself. We'll call // sender.sendJSON() and CDP will send it to the client. But some // comamnds are dispatched internally, in which cases the Sender will // be code to capture the data that we were "sending". sender: Sender, const Self = @This(); pub fn params(self: *const Self, comptime T: type) !?T { if (self.input.params) |p| { return try json.parseFromSliceLeaky( T, self.arena, p.raw, .{ .ignore_unknown_fields = true }, ); } return null; } pub fn createBrowserContext(self: *Self) !*BrowserContext(CDP_T) { _ = try self.cdp.createBrowserContext(); self.browser_context = &(self.cdp.browser_context.?); return self.browser_context.?; } const SendResultOpts = struct { include_session_id: bool = true, }; pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void { return self.sender.sendJSON(.{ .id = self.input.id, .result = if (comptime @typeInfo(@TypeOf(result)) == .null) struct {}{} else result, .sessionId = if (opts.include_session_id) self.input.session_id else null, }); } const SendEventOpts = struct { session_id: ?[]const u8 = null, }; pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: CDP_T.SendEventOpts) !void { // Events ALWAYS go to the client. self.sender should not be used return self.cdp.sendEvent(method, p, opts); } const SendErrorOpts = struct { include_session_id: bool = true, }; pub fn sendError(self: *Self, code: i32, message: []const u8, opts: SendErrorOpts) !void { return self.sender.sendJSON(.{ .id = self.input.id, .@"error" = .{ .code = code, .message = message }, .sessionId = if (opts.include_session_id) self.input.session_id else null, }); } const Input = struct { // When we reply to a message, we echo back the message id id: ?i64, // The "action" of the message.Given a method of "LOG.enable", the // action is "enable" action: []const u8, // See notes in BrowserContext about session_id session_id: ?[]const u8, // Unparsed / untyped input.params. params: ?InputParams, // The full raw json input json: []const u8, }; }; } // When we parse a JSON message from the client, this is the structure // we always expect const InputMessage = struct { id: ?i64 = null, method: []const u8, params: ?InputParams = null, sessionId: ?[]const u8 = null, }; // The JSON "params" field changes based on the "method". Initially, we just // capture the raw json object (including the opening and closing braces). // Then, when we're processing the message, and we know what type it is, we // can parse it (in Disaptch(T).params). const InputParams = struct { raw: []const u8, pub fn jsonParse( _: Allocator, scanner: *json.Scanner, _: json.ParseOptions, ) !InputParams { const height = scanner.stackHeight(); const start = scanner.cursor; if (try scanner.next() != .object_begin) { return error.UnexpectedToken; } try scanner.skipUntilStackHeight(height); const end = scanner.cursor; return .{ .raw = scanner.input[start..end] }; } }; fn asUint(comptime T: type, comptime string: []const u8) T { return @bitCast(string[0..string.len].*); } const testing = @import("testing.zig"); test "cdp: invalid json" { var ctx = testing.context(); defer ctx.deinit(); try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid")); // method is required try testing.expectError(error.InvalidJSON, ctx.processMessage(.{})); try ctx.processMessage(.{ .method = "Target", }); try ctx.expectSentError(-31998, "InvalidMethod", .{}); try ctx.processMessage(.{ .method = "Unknown.domain", }); try ctx.expectSentError(-31998, "UnknownDomain", .{}); try ctx.processMessage(.{ .method = "Target.over9000", }); try ctx.expectSentError(-31998, "UnknownMethod", .{}); } test "cdp: invalid sessionId" { var ctx = testing.context(); defer ctx.deinit(); { // we have no browser context try ctx.processMessage(.{ .method = "Hi", .sessionId = "nope" }); try ctx.expectSentError(-32001, "Unknown sessionId", .{}); } { // we have a brower context but no session_id _ = try ctx.loadBrowserContext(.{}); try ctx.processMessage(.{ .method = "Hi", .sessionId = "BC-Has-No-SessionId" }); try ctx.expectSentError(-32001, "Unknown sessionId", .{}); } { // we have a brower context with a different session_id _ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" }); try ctx.processMessage(.{ .method = "Hi", .sessionId = "SESS-1" }); try ctx.expectSentError(-32001, "Unknown sessionId", .{}); } } test "cdp: STARTUP sessionId" { var ctx = testing.context(); defer ctx.deinit(); { // we have no browser context try ctx.processMessage(.{ .id = 2, .method = "Hi", .sessionId = "STARTUP" }); try ctx.expectSentResult(null, .{ .id = 2, .index = 0, .session_id = "STARTUP" }); } { // we have a brower context but no session_id _ = try ctx.loadBrowserContext(.{}); try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" }); try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" }); } { // we have a brower context with a different session_id _ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" }); try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" }); try ctx.expectSentResult(null, .{ .id = 4, .index = 0, .session_id = "STARTUP" }); } } ================================================ FILE: src/cdp/domains/accessibility.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const id = @import("../id.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, disable, getFullAXTree, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return enable(cmd), .disable => return disable(cmd), .getFullAXTree => return getFullAXTree(cmd), } } fn enable(cmd: anytype) !void { return cmd.sendResult(null, .{}); } fn disable(cmd: anytype) !void { return cmd.sendResult(null, .{}); } fn getFullAXTree(cmd: anytype) !void { const params = (try cmd.params(struct { depth: ?i32 = null, frameId: ?[]const u8 = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const session = bc.session; const page = blk: { const frame_id = params.frameId orelse { break :blk session.currentPage() orelse return error.PageNotLoaded; }; const page_frame_id = try id.toPageId(.frame_id, frame_id); break :blk session.findPageByFrameId(page_frame_id) orelse { return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); }; }; const doc = page.window._document.asNode(); const node = try bc.node_registry.register(doc); return cmd.sendResult(.{ .nodes = try bc.axnodeWriter(node, .{}) }, .{}); } ================================================ FILE: src/cdp/domains/browser.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); // TODO: hard coded data const PROTOCOL_VERSION = "1.3"; const REVISION = "@9e6ded5ac1ff5e38d930ae52bd9aec09bd1a68e4"; // CDP_USER_AGENT const is used by the CDP server only to identify itself to // the CDP clients. // Many clients check the CDP server is a Chrome browser. // // CDP_USER_AGENT const is not used by the browser for the HTTP client (see // src/http/client.zig) nor exposed to the JS (see // src/browser/html/navigator.zig). const CDP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; const PRODUCT = "Chrome/124.0.6367.29"; const JS_VERSION = "12.4.254.8"; const DEV_TOOLS_WINDOW_ID = 1923710101; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { getVersion, setPermission, setWindowBounds, resetPermissions, grantPermissions, getWindowForTarget, setDownloadBehavior, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getVersion => return getVersion(cmd), .setPermission => return setPermission(cmd), .setWindowBounds => return setWindowBounds(cmd), .resetPermissions => return resetPermissions(cmd), .grantPermissions => return grantPermissions(cmd), .getWindowForTarget => return getWindowForTarget(cmd), .setDownloadBehavior => return setDownloadBehavior(cmd), } } fn getVersion(cmd: anytype) !void { // TODO: pre-serialize? return cmd.sendResult(.{ .protocolVersion = PROTOCOL_VERSION, .product = PRODUCT, .revision = REVISION, .userAgent = CDP_USER_AGENT, .jsVersion = JS_VERSION, }, .{ .include_session_id = false }); } // TODO: noop method fn setDownloadBehavior(cmd: anytype) !void { // const params = (try cmd.params(struct { // behavior: []const u8, // browserContextId: ?[]const u8 = null, // downloadPath: ?[]const u8 = null, // eventsEnabled: ?bool = null, // })) orelse return error.InvalidParams; return cmd.sendResult(null, .{ .include_session_id = false }); } fn getWindowForTarget(cmd: anytype) !void { // const params = (try cmd.params(struct { // targetId: ?[]const u8 = null, // })) orelse return error.InvalidParams; return cmd.sendResult(.{ .windowId = DEV_TOOLS_WINDOW_ID, .bounds = .{ .windowState = "normal", } }, .{}); } // TODO: noop method fn setWindowBounds(cmd: anytype) !void { return cmd.sendResult(null, .{}); } // TODO: noop method fn grantPermissions(cmd: anytype) !void { return cmd.sendResult(null, .{}); } // TODO: noop method fn setPermission(cmd: anytype) !void { return cmd.sendResult(null, .{}); } // TODO: noop method fn resetPermissions(cmd: anytype) !void { return cmd.sendResult(null, .{}); } const testing = @import("../testing.zig"); test "cdp.browser: getVersion" { var ctx = testing.context(); defer ctx.deinit(); try ctx.processMessage(.{ .id = 32, .method = "Browser.getVersion", }); try ctx.expectSentCount(1); try ctx.expectSentResult(.{ .protocolVersion = PROTOCOL_VERSION, .product = PRODUCT, .revision = REVISION, .userAgent = CDP_USER_AGENT, .jsVersion = JS_VERSION, }, .{ .id = 32, .index = 0, .session_id = null }); } test "cdp.browser: getWindowForTarget" { var ctx = testing.context(); defer ctx.deinit(); try ctx.processMessage(.{ .id = 33, .method = "Browser.getWindowForTarget", }); try ctx.expectSentCount(1); try ctx.expectSentResult(.{ .windowId = DEV_TOOLS_WINDOW_ID, .bounds = .{ .windowState = "normal" }, }, .{ .id = 33, .index = 0, .session_id = null }); } ================================================ FILE: src/cdp/domains/css.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), } } ================================================ FILE: src/cdp/domains/dom.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const id = @import("../id.zig"); const log = @import("../../log.zig"); const Node = @import("../Node.zig"); const DOMNode = @import("../../browser/webapi/Node.zig"); const Selector = @import("../../browser/webapi/selector/Selector.zig"); const dump = @import("../../browser/dump.zig"); const js = @import("../../browser/js/js.zig"); const Allocator = std.mem.Allocator; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, getDocument, performSearch, getSearchResults, discardSearchResults, querySelector, querySelectorAll, resolveNode, describeNode, scrollIntoViewIfNeeded, getContentQuads, getBoxModel, requestChildNodes, getFrameOwner, getOuterHTML, requestNode, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), .getDocument => return getDocument(cmd), .performSearch => return performSearch(cmd), .getSearchResults => return getSearchResults(cmd), .discardSearchResults => return discardSearchResults(cmd), .querySelector => return querySelector(cmd), .querySelectorAll => return querySelectorAll(cmd), .resolveNode => return resolveNode(cmd), .describeNode => return describeNode(cmd), .scrollIntoViewIfNeeded => return scrollIntoViewIfNeeded(cmd), .getContentQuads => return getContentQuads(cmd), .getBoxModel => return getBoxModel(cmd), .requestChildNodes => return requestChildNodes(cmd), .getFrameOwner => return getFrameOwner(cmd), .getOuterHTML => return getOuterHTML(cmd), .requestNode => return requestNode(cmd), } } // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getDocument fn getDocument(cmd: anytype) !void { const Params = struct { // CDP documentation implies that 0 isn't valid, but it _does_ work in Chrome depth: i32 = 3, pierce: bool = false, }; const params = try cmd.params(Params) orelse Params{}; if (params.pierce) { log.warn(.not_implemented, "DOM.getDocument", .{ .param = "pierce" }); } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node = try bc.node_registry.register(page.window._document.asNode()); return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); } // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch fn performSearch(cmd: anytype) !void { const params = (try cmd.params(struct { query: []const u8, includeUserAgentShadowDOM: ?bool = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const list = try Selector.querySelectorAll(page.window._document.asNode(), params.query, page); defer list.deinit(page._session); const search = try bc.node_search_list.create(list._nodes); // dispatch setChildNodesEvents to inform the client of the subpart of node // tree covering the results. try dispatchSetChildNodes(cmd, list._nodes); return cmd.sendResult(.{ .searchId = search.name, .resultCount = @as(u32, @intCast(search.node_ids.len)), }, .{}); } // dispatchSetChildNodes send the setChildNodes event for the whole DOM tree // hierarchy of each nodes. // We dispatch event in the reverse order: from the top level to the direct parents. // We should dispatch a node only if it has never been sent. fn dispatchSetChildNodes(cmd: anytype, dom_nodes: []const *DOMNode) !void { const arena = cmd.arena; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const session_id = bc.session_id orelse return error.SessionIdNotLoaded; var parents: std.ArrayList(*Node) = .empty; for (dom_nodes) |dom_node| { var current = dom_node; while (true) { const parent_node = current._parent orelse break; const node = try bc.node_registry.register(parent_node); if (node.set_child_nodes_event) { break; } try parents.append(arena, node); current = parent_node; } } const plen = parents.items.len; if (plen == 0) { return; } var i: usize = plen; // We're going to iterate in reverse order from how we added them. // This ensures that we're emitting the tree of nodes top-down. while (i > 0) { i -= 1; const node = parents.items[i]; // Although our above loop won't add an already-sent node to `parents` // this can still be true because two nodes can share the same parent node // so we might have just sent the node a previous iteration of this loop if (node.set_child_nodes_event) continue; node.set_child_nodes_event = true; // If the node has no parent, it's the root node. // We don't dispatch event for it because we assume the root node is // dispatched via the DOM.getDocument command. const dom_parent = node.dom._parent orelse continue; // Retrieve the parent from the registry. const parent_node = try bc.node_registry.register(dom_parent); try cmd.sendEvent("DOM.setChildNodes", .{ .parentId = parent_node.id, .nodes = .{bc.nodeWriter(node, .{})}, }, .{ .session_id = session_id, }); } } // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults fn discardSearchResults(cmd: anytype) !void { const params = (try cmd.params(struct { searchId: []const u8, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; bc.node_search_list.remove(params.searchId); return cmd.sendResult(null, .{}); } // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getSearchResults fn getSearchResults(cmd: anytype) !void { const params = (try cmd.params(struct { searchId: []const u8, fromIndex: u32, toIndex: u32, })) orelse return error.InvalidParams; if (params.fromIndex >= params.toIndex) { return error.BadIndices; } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const search = bc.node_search_list.get(params.searchId) orelse { return error.SearchResultNotFound; }; const node_ids = search.node_ids; if (params.fromIndex >= node_ids.len) return error.BadFromIndex; if (params.toIndex > node_ids.len) return error.BadToIndex; return cmd.sendResult(.{ .nodeIds = node_ids[params.fromIndex..params.toIndex] }, .{}); } fn querySelector(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: Node.Id, selector: []const u8, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { return cmd.sendError(-32000, "Could not find node with given id", .{}); }; const element = try Selector.querySelector(node.dom, params.selector, page) orelse return error.NodeNotFoundForGivenId; const dom_node = element.asNode(); const registered_node = try bc.node_registry.register(dom_node); // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. var array = [1]*DOMNode{dom_node}; try dispatchSetChildNodes(cmd, array[0..]); return cmd.sendResult(.{ .nodeId = registered_node.id, }, .{}); } fn querySelectorAll(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: Node.Id, selector: []const u8, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { return cmd.sendError(-32000, "Could not find node with given id", .{}); }; const selected_nodes = try Selector.querySelectorAll(node.dom, params.selector, page); defer selected_nodes.deinit(page._session); const nodes = selected_nodes._nodes; const node_ids = try cmd.arena.alloc(Node.Id, nodes.len); for (nodes, node_ids) |selected_node, *node_id| { node_id.* = (try bc.node_registry.register(selected_node)).id; } // Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results. try dispatchSetChildNodes(cmd, nodes); return cmd.sendResult(.{ .nodeIds = node_ids, }, .{}); } fn resolveNode(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: ?Node.Id = null, backendNodeId: ?u32 = null, objectGroup: ?[]const u8 = null, executionContextId: ?u32 = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; var ls: ?js.Local.Scope = null; defer if (ls) |*_ls| { _ls.deinit(); }; if (params.executionContextId) |context_id| blk: { ls = undefined; page.js.localScope(&ls.?); if (ls.?.local.debugContextId() == context_id) { break :blk; } // not the default scope, check the other ones for (bc.isolated_worlds.items) |isolated_world| { ls.?.deinit(); ls = null; const ctx = (isolated_world.context orelse return error.ContextNotFound); ls = undefined; ctx.localScope(&ls.?); if (ls.?.local.debugContextId() == context_id) { break :blk; } } else return error.ContextNotFound; } else { ls = undefined; page.js.localScope(&ls.?); } const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode; // node._node is a *DOMNode we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement // So we use the Node.Union when retrieve the value from the environment const remote_object = try bc.inspector_session.getRemoteObject( &ls.?.local, params.objectGroup orelse "", node.dom, ); defer remote_object.deinit(); const arena = cmd.arena; return cmd.sendResult(.{ .object = .{ .type = try remote_object.getType(arena), .subtype = try remote_object.getSubtype(arena), .className = try remote_object.getClassName(arena), .description = try remote_object.getDescription(arena), .objectId = try remote_object.getObjectId(arena), } }, .{}); } fn describeNode(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, objectId: ?[]const u8 = null, depth: i32 = 1, pierce: bool = false, })) orelse return error.InvalidParams; if (params.pierce) { log.warn(.not_implemented, "DOM.describeNode", .{ .param = "pierce" }); } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{}); } // An array of quad vertices, x immediately followed by y for each point, points clock-wise. // Note Y points downward // We are assuming the start/endpoint is not repeated. const Quad = [8]f64; const BoxModel = struct { content: Quad, padding: Quad, border: Quad, margin: Quad, width: i32, height: i32, // shapeOutside: ?ShapeOutsideInfo, }; fn rectToQuad(rect: DOMNode.Element.DOMRect) Quad { return Quad{ rect._x, rect._y, rect._x + rect._width, rect._y, rect._x + rect._width, rect._y + rect._height, rect._x, rect._y + rect._height, }; } fn scrollIntoViewIfNeeded(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: ?Node.Id = null, backendNodeId: ?u32 = null, objectId: ?[]const u8 = null, rect: ?DOMNode.Element.DOMRect = null, })) orelse return error.InvalidParams; // Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null // We retrieve the node to at least check if it exists and is valid. const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); switch (node.dom._type) { .element => {}, .document => {}, .cdata => {}, else => return error.NodeDoesNotHaveGeometry, } return cmd.sendResult(null, .{}); } fn getNode(arena: Allocator, bc: anytype, node_id: ?Node.Id, backend_node_id: ?Node.Id, object_id: ?[]const u8) !*Node { const input_node_id = node_id orelse backend_node_id; if (input_node_id) |input_node_id_| { return bc.node_registry.lookup_by_id.get(input_node_id_) orelse return error.NodeNotFound; } if (object_id) |object_id_| { const page = bc.session.currentPage() orelse return error.PageNotLoaded; var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); // Retrieve the object from which ever context it is in. const parser_node = try bc.inspector_session.getNodePtr(arena, object_id_, &ls.local); return try bc.node_registry.register(@ptrCast(@alignCast(parser_node))); } return error.MissingParams; } // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getContentQuads // Related to: https://drafts.csswg.org/cssom-view/#the-geometryutils-interface fn getContentQuads(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, objectId: ?[]const u8 = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); // TODO likely if the following CSS properties are set the quads should be empty // visibility: hidden // display: none const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement; // TODO implement for document or text // Most likely document would require some hierachgy in the renderer. It is left unimplemented till we have a good example. // Text may be tricky, multiple quads in case of multiple lines? empty quads of text = ""? // Elements like SVGElement may have multiple quads. const quad = rectToQuad(element.getBoundingClientRect(page)); return cmd.sendResult(.{ .quads = &.{quad} }, .{}); } fn getBoxModel(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: ?Node.Id = null, backendNodeId: ?u32 = null, objectId: ?[]const u8 = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); // TODO implement for document or text const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement; const rect = element.getBoundingClientRect(page); const quad = rectToQuad(rect); const zero = [_]f64{0.0} ** 8; return cmd.sendResult(.{ .model = BoxModel{ .content = quad, .padding = zero, .border = zero, .margin = zero, .width = @intFromFloat(rect._width), .height = @intFromFloat(rect._height), } }, .{}); } fn requestChildNodes(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: Node.Id, depth: i32 = 1, pierce: bool = false, })) orelse return error.InvalidParams; if (params.depth == 0) return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const session_id = bc.session_id orelse return error.SessionIdNotLoaded; const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse { return error.InvalidNode; }; try cmd.sendEvent("DOM.setChildNodes", .{ .parentId = node.id, .nodes = bc.nodeWriter(node, .{ .depth = params.depth, .exclude_root = true }), }, .{ .session_id = session_id, }); return cmd.sendResult(null, .{}); } fn getFrameOwner(cmd: anytype) !void { const params = (try cmd.params(struct { frameId: []const u8, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page_frame_id = try id.toPageId(.frame_id, params.frameId); const page = bc.session.findPageByFrameId(page_frame_id) orelse { return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); }; const node = try bc.node_registry.register(page.window._document.asNode()); return cmd.sendResult(.{ .nodeId = node.id, .backendNodeId = node.id }, .{}); } fn getOuterHTML(cmd: anytype) !void { const params = (try cmd.params(struct { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, objectId: ?[]const u8 = null, includeShadowDOM: bool = false, })) orelse return error.InvalidParams; if (params.includeShadowDOM) { log.warn(.not_implemented, "DOM.getOuterHTML", .{ .param = "includeShadowDOM" }); } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); var aw = std.Io.Writer.Allocating.init(cmd.arena); try dump.deep(node.dom, .{}, &aw.writer, page); return cmd.sendResult(.{ .outerHTML = aw.written() }, .{}); } fn requestNode(cmd: anytype) !void { const params = (try cmd.params(struct { objectId: []const u8, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const node = try getNode(cmd.arena, bc, null, null, params.objectId); return cmd.sendResult(.{ .nodeId = node.id }, .{}); } const testing = @import("../testing.zig"); test "cdp.dom: getSearchResults unknown search id" { var ctx = testing.context(); defer ctx.deinit(); try ctx.processMessage(.{ .id = 8, .method = "DOM.getSearchResults", .params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 }, }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 8 }); } test "cdp.dom: search flow" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); try ctx.processMessage(.{ .id = 12, .method = "DOM.performSearch", .params = .{ .query = "p" }, }); try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 12 }); { // getSearchResults try ctx.processMessage(.{ .id = 13, .method = "DOM.getSearchResults", .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 2 }, }); try ctx.expectSentResult(.{ .nodeIds = &.{ 1, 2 } }, .{ .id = 13 }); // different fromIndex try ctx.processMessage(.{ .id = 14, .method = "DOM.getSearchResults", .params = .{ .searchId = "0", .fromIndex = 1, .toIndex = 2 }, }); try ctx.expectSentResult(.{ .nodeIds = &.{2} }, .{ .id = 14 }); // different toIndex try ctx.processMessage(.{ .id = 15, .method = "DOM.getSearchResults", .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, }); try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 15 }); } try ctx.processMessage(.{ .id = 16, .method = "DOM.discardSearchResults", .params = .{ .searchId = "0" }, }); try ctx.expectSentResult(null, .{ .id = 16 }); // make sure the delete actually did something try ctx.processMessage(.{ .id = 17, .method = "DOM.getSearchResults", .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, }); try ctx.expectSentError(-31998, "SearchResultNotFound", .{ .id = 17 }); } test "cdp.dom: querySelector unknown search id" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); try ctx.processMessage(.{ .id = 9, .method = "DOM.querySelector", .params = .{ .nodeId = 99, .selector = "" }, }); try ctx.expectSentError(-32000, "Could not find node with given id", .{}); try ctx.processMessage(.{ .id = 9, .method = "DOM.querySelectorAll", .params = .{ .nodeId = 99, .selector = "" }, }); try ctx.expectSentError(-32000, "Could not find node with given id", .{}); } test "cdp.dom: querySelector Node not found" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry .id = 3, .method = "DOM.performSearch", .params = .{ .query = "p" }, }); try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 }); try ctx.processMessage(.{ .id = 4, .method = "DOM.querySelector", .params = .{ .nodeId = 1, .selector = "a" }, }); try ctx.expectSentError(-31998, "NodeNotFoundForGivenId", .{ .id = 4 }); try ctx.processMessage(.{ .id = 5, .method = "DOM.querySelectorAll", .params = .{ .nodeId = 1, .selector = "a" }, }); try ctx.expectSentResult(.{ .nodeIds = &[_]u32{} }, .{ .id = 5 }); } test "cdp.dom: querySelector Nodes found" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" }); try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry .id = 3, .method = "DOM.performSearch", .params = .{ .query = "div" }, }); try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 3 }); try ctx.processMessage(.{ .id = 4, .method = "DOM.querySelector", .params = .{ .nodeId = 1, .selector = "p" }, }); try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); try ctx.expectSentResult(.{ .nodeId = 7 }, .{ .id = 4 }); try ctx.processMessage(.{ .id = 5, .method = "DOM.querySelectorAll", .params = .{ .nodeId = 1, .selector = "p" }, }); try ctx.expectSentEvent("DOM.setChildNodes", null, .{}); try ctx.expectSentResult(.{ .nodeIds = &.{7} }, .{ .id = 5 }); } test "cdp.dom: getBoxModel" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" }); try ctx.processMessage(.{ // Hacky way to make sure nodeId 1 exists in the registry .id = 3, .method = "DOM.getDocument", }); try ctx.processMessage(.{ .id = 4, .method = "DOM.querySelector", .params = .{ .nodeId = 1, .selector = "p" }, }); try ctx.expectSentResult(.{ .nodeId = 3 }, .{ .id = 4 }); try ctx.processMessage(.{ .id = 5, .method = "DOM.getBoxModel", .params = .{ .nodeId = 6 }, }); try ctx.expectSentResult(.{ .model = BoxModel{ .content = Quad{ 10.0, 10.0, 15.0, 10.0, 15.0, 15.0, 10.0, 15.0 }, .padding = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, .border = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, .margin = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, .width = 5, .height = 5, } }, .{ .id = 5 }); } ================================================ FILE: src/cdp/domains/emulation.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../log.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { setEmulatedMedia, setFocusEmulationEnabled, setDeviceMetricsOverride, setTouchEmulationEnabled, setUserAgentOverride, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .setEmulatedMedia => return setEmulatedMedia(cmd), .setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd), .setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd), .setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd), .setUserAgentOverride => return setUserAgentOverride(cmd), } } // TODO: noop method fn setEmulatedMedia(cmd: anytype) !void { // const input = (try const incoming.params(struct { // media: ?[]const u8 = null, // features: ?[]struct{ // name: []const u8, // value: [] const u8 // } = null, // })) orelse return error.InvalidParams; return cmd.sendResult(null, .{}); } // TODO: noop method fn setFocusEmulationEnabled(cmd: anytype) !void { // const input = (try const incoming.params(struct { // enabled: bool, // })) orelse return error.InvalidParams; return cmd.sendResult(null, .{}); } // TODO: noop method fn setDeviceMetricsOverride(cmd: anytype) !void { return cmd.sendResult(null, .{}); } // TODO: noop method fn setTouchEmulationEnabled(cmd: anytype) !void { return cmd.sendResult(null, .{}); } fn setUserAgentOverride(cmd: anytype) !void { log.info(.app, "setUserAgentOverride ignored", .{}); return cmd.sendResult(null, .{}); } ================================================ FILE: src/cdp/domains/fetch.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Allocator = std.mem.Allocator; const id = @import("../id.zig"); const log = @import("../../log.zig"); const network = @import("network.zig"); const HttpClient = @import("../../browser/HttpClient.zig"); const net_http = @import("../../network/http.zig"); const Notification = @import("../../Notification.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { disable, enable, continueRequest, failRequest, fulfillRequest, continueWithAuth, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .disable => return disable(cmd), .enable => return enable(cmd), .continueRequest => return continueRequest(cmd), .continueWithAuth => return continueWithAuth(cmd), .failRequest => return failRequest(cmd), .fulfillRequest => return fulfillRequest(cmd), } } // Stored in CDP pub const InterceptState = struct { allocator: Allocator, waiting: std.AutoArrayHashMapUnmanaged(u32, *HttpClient.Transfer), pub fn init(allocator: Allocator) !InterceptState { return .{ .waiting = .empty, .allocator = allocator, }; } pub fn empty(self: *const InterceptState) bool { return self.waiting.count() == 0; } pub fn put(self: *InterceptState, transfer: *HttpClient.Transfer) !void { return self.waiting.put(self.allocator, transfer.id, transfer); } pub fn remove(self: *InterceptState, request_id: u32) ?*HttpClient.Transfer { const entry = self.waiting.fetchSwapRemove(request_id) orelse return null; return entry.value; } pub fn deinit(self: *InterceptState) void { self.waiting.deinit(self.allocator); } pub fn pendingTransfers(self: *const InterceptState) []*HttpClient.Transfer { return self.waiting.values(); } }; const RequestPattern = struct { // Wildcards ('*' -> zero or more, '?' -> exactly one) are allowed. // Escape character is backslash. Omitting is equivalent to "*". urlPattern: []const u8 = "*", resourceType: ?ResourceType = null, requestStage: RequestStage = .Request, }; const ResourceType = enum { Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest, SignedExchange, Ping, CSPViolationReport, Preflight, FedCM, Other, }; const RequestStage = enum { Request, Response, }; const EnableParam = struct { patterns: []RequestPattern = &.{}, handleAuthRequests: bool = false, }; const ErrorReason = enum { Failed, Aborted, TimedOut, AccessDenied, ConnectionClosed, ConnectionReset, ConnectionRefused, ConnectionAborted, ConnectionFailed, NameNotResolved, InternetDisconnected, AddressUnreachable, BlockedByClient, BlockedByResponse, }; fn disable(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; bc.fetchDisable(); return cmd.sendResult(null, .{}); } fn enable(cmd: anytype) !void { const params = (try cmd.params(EnableParam)) orelse EnableParam{}; if (!arePatternsSupported(params.patterns)) { log.warn(.not_implemented, "Fetch.enable", .{ .params = "pattern" }); return cmd.sendResult(null, .{}); } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; try bc.fetchEnable(params.handleAuthRequests); return cmd.sendResult(null, .{}); } fn arePatternsSupported(patterns: []RequestPattern) bool { if (patterns.len == 0) { return true; } if (patterns.len > 1) { return false; } // While we don't support patterns, yet, both Playwright and Puppeteer send // a default pattern which happens to be what we support: // [{"urlPattern":"*","requestStage":"Request"}] // So, rather than erroring on this case because we don't support patterns, // we'll allow it, because this pattern is how it works as-is. const pattern = patterns[0]; if (!std.mem.eql(u8, pattern.urlPattern, "*")) { return false; } if (pattern.resourceType != null) { return false; } if (pattern.requestStage != .Request) { return false; } return true; } pub fn requestIntercept(bc: anytype, intercept: *const Notification.RequestIntercept) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; // We keep it around to wait for modifications to the request. // NOTE: we assume whomever created the request created it with a lifetime of the Page. // TODO: What to do when receiving replies for a previous page's requests? const transfer = intercept.transfer; try bc.intercept_state.put(transfer); try bc.cdp.sendEvent("Fetch.requestPaused", .{ .requestId = &id.toInterceptId(transfer.id), .frameId = &id.toFrameId(transfer.req.frame_id), .request = network.TransferAsRequestWriter.init(transfer), .resourceType = switch (transfer.req.resource_type) { .script => "Script", .xhr => "XHR", .document => "Document", .fetch => "Fetch", }, .networkId = &id.toRequestId(transfer.id), // matches the Network REQ-ID }, .{ .session_id = session_id }); log.debug(.cdp, "request intercept", .{ .state = "paused", .id = transfer.id, .url = transfer.url, }); // Await either continueRequest, failRequest or fulfillRequest intercept.wait_for_interception.* = true; } fn continueRequest(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { requestId: []const u8, // INT-{d}" url: ?[]const u8 = null, method: ?[]const u8 = null, postData: ?[]const u8 = null, headers: ?[]const net_http.Header = null, interceptResponse: bool = false, })) orelse return error.InvalidParams; if (params.interceptResponse) { return error.NotImplemented; } var intercept_state = &bc.intercept_state; const request_id = try idFromRequestId(params.requestId); const transfer = intercept_state.remove(request_id) orelse return error.RequestNotFound; log.debug(.cdp, "request intercept", .{ .state = "continue", .id = transfer.id, .url = transfer.url, .new_url = params.url, }); const arena = transfer.arena.allocator(); // Update the request with the new parameters if (params.url) |url| { try transfer.updateURL(try arena.dupeZ(u8, url)); } if (params.method) |method| { transfer.req.method = std.meta.stringToEnum(net_http.Method, method) orelse return error.InvalidParams; } if (params.headers) |headers| { // Not obvious, but cmd.arena is safe here, since the headers will get // duped by libcurl. transfer.arena is more obvious/safe, but cmd.arena // is more efficient (it's re-used) try transfer.replaceRequestHeaders(cmd.arena, headers); } if (params.postData) |b| { const decoder = std.base64.standard.Decoder; const body = try arena.alloc(u8, try decoder.calcSizeForSlice(b)); try decoder.decode(body, b); transfer.req.body = body; } try bc.cdp.browser.http_client.continueTransfer(transfer); return cmd.sendResult(null, .{}); } // https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#type-AuthChallengeResponse const AuthChallengeResponse = enum { Default, CancelAuth, ProvideCredentials, }; fn continueWithAuth(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { requestId: []const u8, // "INT-{d}" authChallengeResponse: struct { response: AuthChallengeResponse, username: []const u8 = "", password: []const u8 = "", }, })) orelse return error.InvalidParams; var intercept_state = &bc.intercept_state; const request_id = try idFromRequestId(params.requestId); const transfer = intercept_state.remove(request_id) orelse return error.RequestNotFound; log.debug(.cdp, "request intercept", .{ .state = "continue with auth", .id = transfer.id, .response = params.authChallengeResponse.response, }); if (params.authChallengeResponse.response != .ProvideCredentials) { transfer.abortAuthChallenge(); return cmd.sendResult(null, .{}); } // cancel the request, deinit the transfer on error. errdefer transfer.abortAuthChallenge(); // restart the request with the provided credentials. const arena = transfer.arena.allocator(); transfer.updateCredentials( try std.fmt.allocPrintSentinel(arena, "{s}:{s}", .{ params.authChallengeResponse.username, params.authChallengeResponse.password, }, 0), ); transfer.reset(); try bc.cdp.browser.http_client.continueTransfer(transfer); return cmd.sendResult(null, .{}); } fn fulfillRequest(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { requestId: []const u8, // "INT-{d}" responseCode: u16, responseHeaders: ?[]const net_http.Header = null, binaryResponseHeaders: ?[]const u8 = null, body: ?[]const u8 = null, responsePhrase: ?[]const u8 = null, })) orelse return error.InvalidParams; if (params.binaryResponseHeaders != null) { log.warn(.not_implemented, "Fetch.fulfillRequest", .{ .param = "binaryResponseHeaders" }); return error.NotImplemented; } var intercept_state = &bc.intercept_state; const request_id = try idFromRequestId(params.requestId); const transfer = intercept_state.remove(request_id) orelse return error.RequestNotFound; log.debug(.cdp, "request intercept", .{ .state = "fulfilled", .id = transfer.id, .url = transfer.url, .status = params.responseCode, .body = params.body != null, }); var body: ?[]const u8 = null; if (params.body) |b| { const decoder = std.base64.standard.Decoder; const buf = try transfer.arena.allocator().alloc(u8, try decoder.calcSizeForSlice(b)); try decoder.decode(buf, b); body = buf; } try bc.cdp.browser.http_client.fulfillTransfer(transfer, params.responseCode, params.responseHeaders orelse &.{}, body); return cmd.sendResult(null, .{}); } fn failRequest(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { requestId: []const u8, // "INT-{d}" errorReason: ErrorReason, })) orelse return error.InvalidParams; var intercept_state = &bc.intercept_state; const request_id = try idFromRequestId(params.requestId); const transfer = intercept_state.remove(request_id) orelse return error.RequestNotFound; defer bc.cdp.browser.http_client.abortTransfer(transfer); log.info(.cdp, "request intercept", .{ .state = "fail", .id = request_id, .url = transfer.url, .reason = params.errorReason, }); return cmd.sendResult(null, .{}); } pub fn requestAuthRequired(bc: anytype, intercept: *const Notification.RequestAuthRequired) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; // We keep it around to wait for modifications to the request. // NOTE: we assume whomever created the request created it with a lifetime of the Page. // TODO: What to do when receiving replies for a previous page's requests? const transfer = intercept.transfer; try bc.intercept_state.put(transfer); const challenge = transfer._auth_challenge orelse return error.NullAuthChallenge; try bc.cdp.sendEvent("Fetch.authRequired", .{ .requestId = &id.toInterceptId(transfer.id), .frameId = &id.toFrameId(transfer.req.frame_id), .request = network.TransferAsRequestWriter.init(transfer), .resourceType = switch (transfer.req.resource_type) { .script => "Script", .xhr => "XHR", .document => "Document", .fetch => "Fetch", }, .authChallenge = .{ .origin = "", // TODO get origin, could be the proxy address for example. .source = if (challenge.source) |s| (if (s == .server) "Server" else "Proxy") else "", .scheme = if (challenge.scheme) |s| (if (s == .digest) "digest" else "basic") else "", .realm = challenge.realm orelse "", }, .networkId = &id.toRequestId(transfer.id), }, .{ .session_id = session_id }); log.debug(.cdp, "request auth required", .{ .state = "paused", .id = transfer.id, .url = transfer.url, }); // Await continueWithAuth intercept.wait_for_interception.* = true; } // Get u32 from requestId which is formatted as: "INT-{d}" fn idFromRequestId(request_id: []const u8) !u32 { if (!std.mem.startsWith(u8, request_id, "INT-")) { return error.InvalidParams; } return std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams; } ================================================ FILE: src/cdp/domains/input.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { dispatchKeyEvent, dispatchMouseEvent, insertText, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .dispatchKeyEvent => return dispatchKeyEvent(cmd), .dispatchMouseEvent => return dispatchMouseEvent(cmd), .insertText => return insertText(cmd), } } // https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchKeyEvent fn dispatchKeyEvent(cmd: anytype) !void { const params = (try cmd.params(struct { type: Type, key: []const u8 = "", code: ?[]const u8 = null, modifiers: u4 = 0, // Many optional parameters are not implemented yet, see documentation url. const Type = enum { keyDown, keyUp, rawKeyDown, char, }; })) orelse return error.InvalidParams; try cmd.sendResult(null, .{}); // quickly ignore types we know we don't handle switch (params.type) { .keyUp, .rawKeyDown, .char => return, .keyDown => {}, } const bc = cmd.browser_context orelse return; const page = bc.session.currentPage() orelse return; const KeyboardEvent = @import("../../browser/webapi/event/KeyboardEvent.zig"); const keyboard_event = try KeyboardEvent.initTrusted(comptime .wrap("keydown"), .{ .key = params.key, .code = params.code, .altKey = params.modifiers & 1 == 1, .ctrlKey = params.modifiers & 2 == 2, .metaKey = params.modifiers & 4 == 4, .shiftKey = params.modifiers & 8 == 8, }, page); try page.triggerKeyboard(keyboard_event); // result already sent } // https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent fn dispatchMouseEvent(cmd: anytype) !void { const params = (try cmd.params(struct { x: f64, y: f64, type: Type, // Many optional parameters are not implemented yet, see documentation url. const Type = enum { mousePressed, mouseReleased, mouseMoved, mouseWheel, }; })) orelse return error.InvalidParams; try cmd.sendResult(null, .{}); // quickly ignore types we know we don't handle switch (params.type) { .mouseMoved, .mouseWheel, .mouseReleased => return, else => {}, } const bc = cmd.browser_context orelse return; const page = bc.session.currentPage() orelse return; try page.triggerMouseClick(params.x, params.y); // result already sent } // https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-insertText fn insertText(cmd: anytype) !void { const params = (try cmd.params(struct { text: []const u8, // The text to insert })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return; const page = bc.session.currentPage() orelse return; try page.insertText(params.text); try cmd.sendResult(null, .{}); } ================================================ FILE: src/cdp/domains/inspector.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, disable, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), .disable => return cmd.sendResult(null, .{}), } } ================================================ FILE: src/cdp/domains/log.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, disable, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable, .disable => return cmd.sendResult(null, .{}), } } ================================================ FILE: src/cdp/domains/lp.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const log = @import("../../log.zig"); const markdown = lp.markdown; const SemanticTree = lp.SemanticTree; const interactive = lp.interactive; const structured_data = lp.structured_data; const Node = @import("../Node.zig"); const DOMNode = @import("../../browser/webapi/Node.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { getMarkdown, getSemanticTree, getInteractiveElements, getStructuredData, clickNode, fillNode, scrollNode, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getMarkdown => return getMarkdown(cmd), .getSemanticTree => return getSemanticTree(cmd), .getInteractiveElements => return getInteractiveElements(cmd), .getStructuredData => return getStructuredData(cmd), .clickNode => return clickNode(cmd), .fillNode => return fillNode(cmd), .scrollNode => return scrollNode(cmd), } } fn getSemanticTree(cmd: anytype) !void { const Params = struct { format: ?enum { text } = null, prune: ?bool = null, interactiveOnly: ?bool = null, backendNodeId: ?Node.Id = null, maxDepth: ?u32 = null, }; const params = (try cmd.params(Params)) orelse Params{}; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const dom_node = if (params.backendNodeId) |nodeId| (bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom else page.document.asNode(); var st = SemanticTree{ .dom_node = dom_node, .registry = &bc.node_registry, .page = page, .arena = cmd.arena, .prune = params.prune orelse true, .interactive_only = params.interactiveOnly orelse false, .max_depth = params.maxDepth orelse std.math.maxInt(u32) - 1, }; if (params.format) |format| { if (format == .text) { var aw: std.Io.Writer.Allocating = .init(cmd.arena); defer aw.deinit(); try st.textStringify(&aw.writer); return cmd.sendResult(.{ .semanticTree = aw.written(), }, .{}); } } return cmd.sendResult(.{ .semanticTree = st, }, .{}); } fn getMarkdown(cmd: anytype) !void { const Params = struct { nodeId: ?Node.Id = null, }; const params = (try cmd.params(Params)) orelse Params{}; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const dom_node = if (params.nodeId) |nodeId| (bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom else page.document.asNode(); var aw: std.Io.Writer.Allocating = .init(cmd.arena); defer aw.deinit(); try markdown.dump(dom_node, .{}, &aw.writer, page); return cmd.sendResult(.{ .markdown = aw.written(), }, .{}); } fn getInteractiveElements(cmd: anytype) !void { const Params = struct { nodeId: ?Node.Id = null, }; const params = (try cmd.params(Params)) orelse Params{}; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const root = if (params.nodeId) |nodeId| (bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom else page.document.asNode(); const elements = try interactive.collectInteractiveElements(root, cmd.arena, page); // Register nodes so nodeIds are valid for subsequent CDP calls. var node_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, elements.len); for (elements) |el| { const registered = try bc.node_registry.register(el.node); node_ids.appendAssumeCapacity(registered.id); } return cmd.sendResult(.{ .elements = elements, .nodeIds = node_ids.items, }, .{}); } fn getStructuredData(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const data = try structured_data.collectStructuredData( page.document.asNode(), cmd.arena, page, ); return cmd.sendResult(.{ .structuredData = data, }, .{}); } fn clickNode(cmd: anytype) !void { const Params = struct { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, }; const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; return cmd.sendResult(.{}, .{}); } fn fillNode(cmd: anytype) !void { const Params = struct { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, text: []const u8, }; const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; lp.actions.fill(node.dom, params.text, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; return cmd.sendResult(.{}, .{}); } fn scrollNode(cmd: anytype) !void { const Params = struct { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, x: ?i32 = null, y: ?i32 = null, }; const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const maybe_node_id = params.nodeId orelse params.backendNodeId; var target_node: ?*DOMNode = null; if (maybe_node_id) |node_id| { const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; target_node = node.dom; } lp.actions.scroll(target_node, params.x, params.y, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; return cmd.sendResult(.{}, .{}); } const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); _ = try bc.session.createPage(); try ctx.processMessage(.{ .id = 1, .method = "LP.getMarkdown", }); const result = ctx.client.?.sent.items[0].object.get("result").?.object; try testing.expect(result.get("markdown") != null); } test "cdp.lp: getInteractiveElements" { var ctx = testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); _ = try bc.session.createPage(); try ctx.processMessage(.{ .id = 1, .method = "LP.getInteractiveElements", }); const result = ctx.client.?.sent.items[0].object.get("result").?.object; try testing.expect(result.get("elements") != null); try testing.expect(result.get("nodeIds") != null); } test "cdp.lp: getStructuredData" { var ctx = testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); _ = try bc.session.createPage(); try ctx.processMessage(.{ .id = 1, .method = "LP.getStructuredData", }); const result = ctx.client.?.sent.items[0].object.get("result").?.object; try testing.expect(result.get("structuredData") != null); } test "cdp.lp: action tools" { var ctx = testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); const page = try bc.session.createPage(); const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); _ = bc.session.wait(5000); // Test Click const btn = page.document.getElementById("btn", page).?.asNode(); const btn_id = (try bc.node_registry.register(btn)).id; try ctx.processMessage(.{ .id = 1, .method = "LP.clickNode", .params = .{ .backendNodeId = btn_id }, }); // Test Fill Input const inp = page.document.getElementById("inp", page).?.asNode(); const inp_id = (try bc.node_registry.register(inp)).id; try ctx.processMessage(.{ .id = 2, .method = "LP.fillNode", .params = .{ .backendNodeId = inp_id, .text = "hello" }, }); // Test Fill Select const sel = page.document.getElementById("sel", page).?.asNode(); const sel_id = (try bc.node_registry.register(sel)).id; try ctx.processMessage(.{ .id = 3, .method = "LP.fillNode", .params = .{ .backendNodeId = sel_id, .text = "opt2" }, }); // Test Scroll const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); const scrollbox_id = (try bc.node_registry.register(scrollbox)).id; try ctx.processMessage(.{ .id = 4, .method = "LP.scrollNode", .params = .{ .backendNodeId = scrollbox_id, .y = 50 }, }); // Evaluate assertions var ls: lp.js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var try_catch: lp.js.TryCatch = undefined; try_catch.init(&ls.local); defer try_catch.deinit(); const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); try testing.expect(result.isTrue()); } ================================================ FILE: src/cdp/domains/network.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const Allocator = std.mem.Allocator; const log = @import("../../log.zig"); const CdpStorage = @import("storage.zig"); const id = @import("../id.zig"); const URL = @import("../../browser/URL.zig"); const Transfer = @import("../../browser/HttpClient.zig").Transfer; const Notification = @import("../../Notification.zig"); const Mime = @import("../../browser/Mime.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, disable, setCacheDisabled, setExtraHTTPHeaders, setUserAgentOverride, deleteCookies, clearBrowserCookies, setCookie, setCookies, getCookies, getResponseBody, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return enable(cmd), .disable => return disable(cmd), .setCacheDisabled => return cmd.sendResult(null, .{}), .setUserAgentOverride => return cmd.sendResult(null, .{}), .setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd), .deleteCookies => return deleteCookies(cmd), .clearBrowserCookies => return clearBrowserCookies(cmd), .setCookie => return setCookie(cmd), .setCookies => return setCookies(cmd), .getCookies => return getCookies(cmd), .getResponseBody => return getResponseBody(cmd), } } fn enable(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; try bc.networkEnable(); return cmd.sendResult(null, .{}); } fn disable(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; bc.networkDisable(); return cmd.sendResult(null, .{}); } fn setExtraHTTPHeaders(cmd: anytype) !void { const params = (try cmd.params(struct { headers: std.json.ArrayHashMap([]const u8), })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; // Copy the headers onto the browser context arena const arena = bc.arena; const extra_headers = &bc.extra_headers; extra_headers.clearRetainingCapacity(); try extra_headers.ensureTotalCapacity(arena, params.headers.map.count()); var it = params.headers.map.iterator(); while (it.next()) |header| { const header_string = try std.fmt.allocPrintSentinel(arena, "{s}: {s}", .{ header.key_ptr.*, header.value_ptr.* }, 0); extra_headers.appendAssumeCapacity(header_string); } return cmd.sendResult(null, .{}); } const Cookie = @import("../../browser/webapi/storage/storage.zig").Cookie; // Only matches the cookie on provided parameters fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool { if (!std.mem.eql(u8, cookie.name, name)) return false; if (domain) |domain_| { const c_no_dot = if (std.mem.startsWith(u8, cookie.domain, ".")) cookie.domain[1..] else cookie.domain; const d_no_dot = if (std.mem.startsWith(u8, domain_, ".")) domain_[1..] else domain_; if (!std.mem.eql(u8, c_no_dot, d_no_dot)) return false; } if (path) |path_| { if (!std.mem.eql(u8, cookie.path, path_)) return false; } return true; } fn deleteCookies(cmd: anytype) !void { const params = (try cmd.params(struct { name: []const u8, url: ?[:0]const u8 = null, domain: ?[]const u8 = null, path: ?[]const u8 = null, partitionKey: ?CdpStorage.CookiePartitionKey = null, })) orelse return error.InvalidParams; // Silently ignore partitionKey since we don't support partitioned cookies (CHIPS). // This allows Puppeteer's page.setCookie() to work, which sends deleteCookies // with partitionKey as part of its cookie-setting workflow. if (params.partitionKey != null) { log.warn(.not_implemented, "partition key", .{ .src = "deleteCookies" }); } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const cookies = &bc.session.cookie_jar.cookies; var index = cookies.items.len; while (index > 0) { index -= 1; const cookie = &cookies.items[index]; const domain = try Cookie.parseDomain(cmd.arena, params.url, params.domain); const path = try Cookie.parsePath(cmd.arena, params.url, params.path); // We do not want to use Cookie.appliesTo here. As a Cookie with a shorter path would match. // Similar to deduplicating with areCookiesEqual, except domain and path are optional. if (cookieMatches(cookie, params.name, domain, path)) { cookies.swapRemove(index).deinit(); } } return cmd.sendResult(null, .{}); } fn clearBrowserCookies(cmd: anytype) !void { if (try cmd.params(struct {}) != null) return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; bc.session.cookie_jar.clearRetainingCapacity(); return cmd.sendResult(null, .{}); } fn setCookie(cmd: anytype) !void { const params = (try cmd.params( CdpStorage.CdpCookie, )) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; try CdpStorage.setCdpCookie(&bc.session.cookie_jar, params); try cmd.sendResult(.{ .success = true }, .{}); } fn setCookies(cmd: anytype) !void { const params = (try cmd.params(struct { cookies: []const CdpStorage.CdpCookie, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; for (params.cookies) |param| { try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param); } try cmd.sendResult(null, .{}); } const GetCookiesParam = struct { urls: ?[]const [:0]const u8 = null, }; fn getCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{}; // If not specified, use the URLs of the page and all of its subframes. TODO subframes const page_url = if (bc.session.page) |page| page.url else null; const param_urls = params.urls orelse &[_][:0]const u8{page_url orelse return error.InvalidParams}; var urls = try std.ArrayList(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len); for (param_urls) |url| { urls.appendAssumeCapacity(.{ .host = try Cookie.parseDomain(cmd.arena, url, null), .path = try Cookie.parsePath(cmd.arena, url, null), .secure = URL.isHTTPS(url), }); } var jar = &bc.session.cookie_jar; jar.removeExpired(null); const writer = CdpStorage.CookieWriter{ .cookies = jar.cookies.items, .urls = urls.items }; try cmd.sendResult(.{ .cookies = writer }, .{}); } fn getResponseBody(cmd: anytype) !void { const params = (try cmd.params(struct { requestId: []const u8, // "REQ-{d}" })) orelse return error.InvalidParams; const request_id = try idFromRequestId(params.requestId); const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const buf = bc.captured_responses.getPtr(request_id) orelse return error.RequestNotFound; try cmd.sendResult(.{ .body = buf.items, .base64Encoded = false, }, .{}); } pub fn httpRequestFail(bc: anytype, msg: *const Notification.RequestFail) !void { // It's possible that the request failed because we aborted when the client // sent Target.closeTarget. In that case, bc.session_id will be cleared // already, and we can skip sending these messages to the client. const session_id = bc.session_id orelse return; // Isn't possible to do a network request within a Browser (which our // notification is tied to), without a page. lp.assert(bc.session.page != null, "CDP.network.httpRequestFail null page", .{}); // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.loadingFailed", .{ .requestId = &id.toRequestId(msg.transfer.id), // Seems to be what chrome answers with. I assume it depends on the type of error? .type = "Ping", .errorText = msg.err, .canceled = false, }, .{ .session_id = session_id }); } pub fn httpRequestStart(bc: anytype, msg: *const Notification.RequestStart) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; const transfer = msg.transfer; const req = &transfer.req; const frame_id = req.frame_id; const page = bc.session.findPageByFrameId(frame_id) orelse return; // Modify request with extra CDP headers for (bc.extra_headers.items) |extra| { try req.headers.add(extra); } // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.requestWillBeSent", .{ .loaderId = &id.toLoaderId(transfer.id), .requestId = &id.toRequestId(transfer.id), .frameId = &id.toFrameId(frame_id), .type = req.resource_type.string(), .documentURL = page.url, .request = TransferAsRequestWriter.init(transfer), .initiator = .{ .type = "other" }, .redirectHasExtraInfo = false, // TODO change after adding Network.requestWillBeSentExtraInfo .hasUserGesture = false, }, .{ .session_id = session_id }); } pub fn httpResponseHeaderDone(arena: Allocator, bc: anytype, msg: *const Notification.ResponseHeaderDone) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; const transfer = msg.transfer; // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.responseReceived", .{ .loaderId = &id.toLoaderId(transfer.id), .requestId = &id.toRequestId(transfer.id), .frameId = &id.toFrameId(transfer.req.frame_id), .response = TransferAsResponseWriter.init(arena, msg.transfer), .hasExtraInfo = false, // TODO change after adding Network.responseReceivedExtraInfo }, .{ .session_id = session_id }); } pub fn httpRequestDone(bc: anytype, msg: *const Notification.RequestDone) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; const transfer = msg.transfer; try bc.cdp.sendEvent("Network.loadingFinished", .{ .requestId = &id.toRequestId(transfer.id), .encodedDataLength = transfer.bytes_received, }, .{ .session_id = session_id }); } pub const TransferAsRequestWriter = struct { transfer: *Transfer, pub fn init(transfer: *Transfer) TransferAsRequestWriter { return .{ .transfer = transfer, }; } pub fn jsonStringify(self: *const TransferAsRequestWriter, jws: anytype) !void { self._jsonStringify(jws) catch return error.WriteFailed; } fn _jsonStringify(self: *const TransferAsRequestWriter, jws: anytype) !void { const transfer = self.transfer; try jws.beginObject(); { try jws.objectField("url"); try jws.write(transfer.url); } { const frag = URL.getHash(transfer.url); if (frag.len > 0) { try jws.objectField("urlFragment"); try jws.write(frag); } } { try jws.objectField("method"); try jws.write(@tagName(transfer.req.method)); } { try jws.objectField("hasPostData"); try jws.write(transfer.req.body != null); } { try jws.objectField("headers"); try jws.beginObject(); var it = transfer.req.headers.iterator(); while (it.next()) |hdr| { try jws.objectField(hdr.name); try jws.write(hdr.value); } try jws.endObject(); } try jws.endObject(); } }; const TransferAsResponseWriter = struct { arena: Allocator, transfer: *Transfer, fn init(arena: Allocator, transfer: *Transfer) TransferAsResponseWriter { return .{ .arena = arena, .transfer = transfer, }; } pub fn jsonStringify(self: *const TransferAsResponseWriter, jws: anytype) !void { self._jsonStringify(jws) catch return error.WriteFailed; } fn _jsonStringify(self: *const TransferAsResponseWriter, jws: anytype) !void { const transfer = self.transfer; try jws.beginObject(); { try jws.objectField("url"); try jws.write(transfer.url); } if (transfer.response_header) |*rh| { // it should not be possible for this to be false, but I'm not // feeling brave today. const status = rh.status; try jws.objectField("status"); try jws.write(status); try jws.objectField("statusText"); try jws.write(@as(std.http.Status, @enumFromInt(status)).phrase() orelse "Unknown"); } { const mime: Mime = blk: { if (transfer.response_header.?.contentType()) |ct| { break :blk try Mime.parse(ct); } break :blk .unknown; }; try jws.objectField("mimeType"); try jws.write(mime.contentTypeString()); try jws.objectField("charset"); try jws.write(mime.charsetString()); } { // chromedp doesn't like having duplicate header names. It's pretty // common to get these from a server (e.g. for Cache-Control), but // Chrome joins these. So we have to too. const arena = self.arena; var it = transfer.responseHeaderIterator(); var map: std.StringArrayHashMapUnmanaged([]const u8) = .empty; while (it.next()) |hdr| { const gop = try map.getOrPut(arena, hdr.name); if (gop.found_existing) { // yes, chrome joins multi-value headers with a \n gop.value_ptr.* = try std.mem.join(arena, "\n", &.{ gop.value_ptr.*, hdr.value }); } else { gop.value_ptr.* = hdr.value; } } try jws.objectField("headers"); try jws.write(std.json.ArrayHashMap([]const u8){ .map = map }); } try jws.endObject(); } }; fn idFromRequestId(request_id: []const u8) !u64 { if (!std.mem.startsWith(u8, request_id, "REQ-")) { return error.InvalidParams; } return std.fmt.parseInt(u64, request_id[4..], 10) catch return error.InvalidParams; } const testing = @import("../testing.zig"); test "cdp.network setExtraHTTPHeaders" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "NID-A", .session_id = "NESI-A" }); // try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about/blank" } }); try ctx.processMessage(.{ .id = 3, .method = "Network.setExtraHTTPHeaders", .params = .{ .headers = .{ .foo = "bar" } }, }); try ctx.processMessage(.{ .id = 4, .method = "Network.setExtraHTTPHeaders", .params = .{ .headers = .{ .food = "bars" } }, }); const bc = ctx.cdp().browser_context.?; try testing.expectEqual(bc.extra_headers.items.len, 1); } test "cdp.Network: cookies" { const ResCookie = CdpStorage.ResCookie; const CdpCookie = CdpStorage.CdpCookie; var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-S" }); // Initially empty try ctx.processMessage(.{ .id = 3, .method = "Network.getCookies", .params = .{ .urls = &[_][]const u8{"https://example.com/pancakes"} }, }); try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 }); // Has cookies after setting them try ctx.processMessage(.{ .id = 4, .method = "Network.setCookie", .params = CdpCookie{ .name = "test3", .value = "valuenot3", .url = "https://car.example.com/defnotpancakes" }, }); try ctx.expectSentResult(null, .{ .id = 4 }); try ctx.processMessage(.{ .id = 5, .method = "Network.setCookies", .params = .{ .cookies = &[_]CdpCookie{ .{ .name = "test3", .value = "value3", .url = "https://car.example.com/pan/cakes" }, .{ .name = "test4", .value = "value4", .domain = "example.com", .path = "/mango" }, }, }, }); try ctx.expectSentResult(null, .{ .id = 5 }); try ctx.processMessage(.{ .id = 6, .method = "Network.getCookies", .params = .{ .urls = &[_][]const u8{"https://car.example.com/pan/cakes"} }, }); try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{ .{ .name = "test3", .value = "value3", .domain = "car.example.com", .path = "/", .size = 11, .secure = true }, // No Pancakes! }, }, .{ .id = 6 }); // deleteCookies try ctx.processMessage(.{ .id = 7, .method = "Network.deleteCookies", .params = .{ .name = "test3", .domain = "car.example.com" }, }); try ctx.expectSentResult(null, .{ .id = 7 }); try ctx.processMessage(.{ .id = 8, .method = "Storage.getCookies", .params = .{ .browserContextId = "BID-S" }, }); // Just the untouched test4 should be in the result try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{.{ .name = "test4", .value = "value4", .domain = ".example.com", .path = "/mango", .size = 11 }} }, .{ .id = 8 }); // Empty after clearBrowserCookies try ctx.processMessage(.{ .id = 9, .method = "Network.clearBrowserCookies", }); try ctx.expectSentResult(null, .{ .id = 9 }); try ctx.processMessage(.{ .id = 10, .method = "Storage.getCookies", .params = .{ .browserContextId = "BID-S" }, }); try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 10 }); } ================================================ FILE: src/cdp/domains/page.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const screenshot_png = @embedFile("screenshot.png"); const id = @import("../id.zig"); const log = @import("../../log.zig"); const js = @import("../../browser/js/js.zig"); const URL = @import("../../browser/URL.zig"); const Page = @import("../../browser/Page.zig"); const timestampF = @import("../../datetime.zig").timestamp; const Notification = @import("../../Notification.zig"); const Allocator = std.mem.Allocator; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, getFrameTree, setLifecycleEventsEnabled, addScriptToEvaluateOnNewDocument, createIsolatedWorld, navigate, stopLoading, close, captureScreenshot, getLayoutMetrics, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), .getFrameTree => return getFrameTree(cmd), .setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd), .addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd), .createIsolatedWorld => return createIsolatedWorld(cmd), .navigate => return navigate(cmd), .stopLoading => return cmd.sendResult(null, .{}), .close => return close(cmd), .captureScreenshot => return captureScreenshot(cmd), .getLayoutMetrics => return getLayoutMetrics(cmd), } } const Frame = struct { id: []const u8, loaderId: []const u8, url: []const u8, domainAndRegistry: []const u8 = "", securityOrigin: []const u8, mimeType: []const u8 = "text/html", adFrameStatus: struct { adFrameType: []const u8 = "none", } = .{}, secureContextType: []const u8, crossOriginIsolatedContextType: []const u8 = "NotIsolated", gatedAPIFeatures: [][]const u8 = &[0][]const u8{}, }; fn getFrameTree(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const target_id = bc.target_id orelse return error.TargetNotLoaded; return cmd.sendResult(.{ .frameTree = .{ .frame = Frame{ .id = &target_id, .securityOrigin = bc.security_origin, .loaderId = "LID-0000000001", .url = bc.getURL() orelse "about:blank", .secureContextType = bc.secure_context_type, }, }, }, .{}); } fn setLifecycleEventsEnabled(cmd: anytype) !void { const params = (try cmd.params(struct { enabled: bool, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; if (params.enabled == false) { bc.lifecycleEventsDisable(); return cmd.sendResult(null, .{}); } // Enable lifecycle events. try bc.lifecycleEventsEnable(); // When we enable lifecycle events, we must dispatch events for all // attached targets. const page = bc.session.currentPage() orelse return error.PageNotLoaded; if (page._load_state == .complete) { const frame_id = &id.toFrameId(page._frame_id); const loader_id = &id.toLoaderId(page._req_id); const now = timestampF(.monotonic); try sendPageLifecycle(bc, "DOMContentLoaded", now, frame_id, loader_id); try sendPageLifecycle(bc, "load", now, frame_id, loader_id); const http_client = page._session.browser.http_client; const http_active = http_client.active; const total_network_activity = http_active + http_client.intercepted; if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { try sendPageLifecycle(bc, "networkAlmostIdle", now, frame_id, loader_id); } if (page._notified_network_idle.check(total_network_activity == 0)) { try sendPageLifecycle(bc, "networkIdle", now, frame_id, loader_id); } } return cmd.sendResult(null, .{}); } // TODO: hard coded method // With the command we receive a script we need to store and run for each new document. // Note that the worldName refers to the name given to the isolated world. fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void { // const params = (try cmd.params(struct { // source: []const u8, // worldName: ?[]const u8 = null, // includeCommandLineAPI: bool = false, // runImmediately: bool = false, // })) orelse return error.InvalidParams; return cmd.sendResult(.{ .identifier = "1", }, .{}); } fn close(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const target_id = bc.target_id orelse return error.TargetNotLoaded; // can't be null if we have a target_id lp.assert(bc.session.page != null, "CDP.page.close null page", .{}); try cmd.sendResult(.{}, .{}); // Following code is similar to target.closeTarget // // could be null, created but never attached if (bc.session_id) |session_id| { // Inspector.detached event try cmd.sendEvent("Inspector.detached", .{ .reason = "Render process gone.", }, .{ .session_id = session_id }); // detachedFromTarget event try cmd.sendEvent("Target.detachedFromTarget", .{ .targetId = target_id, .sessionId = session_id, .reason = "Render process gone.", }, .{}); bc.session_id = null; } bc.session.removePage(); for (bc.isolated_worlds.items) |world| { world.deinit(); } bc.isolated_worlds.clearRetainingCapacity(); bc.target_id = null; } fn createIsolatedWorld(cmd: anytype) !void { const params = (try cmd.params(struct { frameId: []const u8, worldName: []const u8, grantUniveralAccess: bool = false, })) orelse return error.InvalidParams; if (!params.grantUniveralAccess) { log.warn(.not_implemented, "Page.createIsolatedWorld", .{ .param = "grantUniveralAccess" }); // When grantUniveralAccess == false and the client attempts to resolve // or otherwise access a DOM or other JS Object from another context that should fail. } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); const page = bc.session.currentPage() orelse return error.PageNotLoaded; const js_context = try world.createContext(page); return cmd.sendResult(.{ .executionContextId = js_context.id }, .{}); } fn navigate(cmd: anytype) !void { const params = (try cmd.params(struct { url: [:0]const u8, // referrer: ?[]const u8 = null, // transitionType: ?[]const u8 = null, // TODO: enum // frameId: ?[]const u8 = null, // referrerPolicy: ?[]const u8 = null, // TODO: enum })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; // didn't create? // const target_id = bc.target_id orelse return error.TargetIdNotLoaded; // didn't attach? if (bc.session_id == null) { return error.SessionIdNotLoaded; } const session = bc.session; var page = session.currentPage() orelse return error.PageNotLoaded; if (page._load_state != .waiting) { page = try session.replacePage(); } const encoded_url = try URL.ensureEncoded(page.call_arena, params.url); try page.navigate(encoded_url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, .kind = .{ .push = null }, }); } pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; bc.reset(); const frame_id = &id.toFrameId(event.frame_id); const loader_id = &id.toLoaderId(event.req_id); var cdp = bc.cdp; const reason_: ?[]const u8 = switch (event.opts.reason) { .anchor => "anchorClick", .script, .history, .navigation => "scriptInitiated", .form => switch (event.opts.method) { .GET => "formSubmissionGet", .POST => "formSubmissionPost", else => unreachable, }, .address_bar => null, .initialFrameNavigation => "initialFrameNavigation", }; if (reason_) |reason| { if (event.opts.reason != .initialFrameNavigation) { try cdp.sendEvent("Page.frameScheduledNavigation", .{ .frameId = frame_id, .delay = 0, .reason = reason, .url = event.url, }, .{ .session_id = session_id }); } try cdp.sendEvent("Page.frameRequestedNavigation", .{ .frameId = frame_id, .reason = reason, .url = event.url, .disposition = "currentTab", }, .{ .session_id = session_id }); } // frameStartedNavigating event try cdp.sendEvent("Page.frameStartedNavigating", .{ .frameId = frame_id, .url = event.url, .loaderId = loader_id, .navigationType = "differentDocument", }, .{ .session_id = session_id }); // frameStartedLoading event try cdp.sendEvent("Page.frameStartedLoading", .{ .frameId = frame_id, }, .{ .session_id = session_id }); } pub fn pageRemove(bc: anytype) !void { // Clear all remote object mappings to prevent stale objectIds from being used // after the context is destroy bc.inspector_session.inspector.resetContextGroup(); // The main page is going to be removed, we need to remove contexts from other worlds first. for (bc.isolated_worlds.items) |isolated_world| { try isolated_world.removeContext(); } } pub fn pageCreated(bc: anytype, page: *Page) !void { _ = bc.cdp.page_arena.reset(.{ .retain_with_limit = 1024 * 512 }); for (bc.isolated_worlds.items) |isolated_world| { _ = try isolated_world.createContext(page); } // Only retain captured responses until a navigation event. In CDP term, // this is called a "renderer" and the cache-duration can be controlled via // the Network.configureDurableMessages message (which we don't support) bc.captured_responses = .empty; } pub fn pageFrameCreated(bc: anytype, event: *const Notification.PageFrameCreated) !void { const session_id = bc.session_id orelse return; const cdp = bc.cdp; const frame_id = &id.toFrameId(event.frame_id); try cdp.sendEvent("Page.frameAttached", .{ .params = .{ .frameId = frame_id, .parentFrameId = &id.toFrameId(event.parent_id), } }, .{ .session_id = session_id }); if (bc.page_life_cycle_events) { try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ .name = "init", .frameId = frame_id, .loaderId = &id.toLoaderId(event.frame_id), .timestamp = event.timestamp, }, .{ .session_id = session_id }); } } pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.PageNavigated) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; const timestamp = event.timestamp; const frame_id = &id.toFrameId(event.frame_id); const loader_id = &id.toLoaderId(event.req_id); var cdp = bc.cdp; // Drivers are sensitive to the order of events. Some more than others. // The result for the Page.navigate seems like it _must_ come after // the frameStartedLoading, but before any lifecycleEvent. So we // unfortunately have to put the input_id ito the NavigateOpts which gets // passed back into the notification. if (event.opts.cdp_id) |input_id| { try cdp.sendJSON(.{ .id = input_id, .result = .{ .frameId = frame_id, .loaderId = loader_id, }, .sessionId = session_id, }); } if (bc.page_life_cycle_events) { try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ .name = "init", .frameId = frame_id, .loaderId = loader_id, .timestamp = event.timestamp, }, .{ .session_id = session_id }); } const reason_: ?[]const u8 = switch (event.opts.reason) { .anchor => "anchorClick", .script, .history, .navigation => "scriptInitiated", .form => switch (event.opts.method) { .GET => "formSubmissionGet", .POST => "formSubmissionPost", else => unreachable, }, .address_bar => null, .initialFrameNavigation => "initialFrameNavigation", }; if (reason_ != null) { try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{ .frameId = frame_id, }, .{ .session_id = session_id }); } const page = bc.session.currentPage() orelse return error.PageNotLoaded; // When we actually recreated the context we should have the inspector send // this event, see: resetContextGroup Sending this event will tell the // client that the context ids they had are invalid and the context shouls // be dropped The client will expect us to send new contextCreated events, // such that the client has new id's for the active contexts. // Only send executionContextsCleared for main frame navigations. For child // frames (iframes), clearing all contexts would destroy the main frame's // context, causing Puppeteer's page.evaluate()/page.content() to hang // forever. if (event.frame_id == page._frame_id) { try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id }); } { const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id }); var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); bc.inspector_session.inspector.contextCreated( &ls.local, "", page.origin orelse "", aux_data, true, ); } for (bc.isolated_worlds.items) |isolated_world| { const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id }); // Calling contextCreated will assign a new Id to the context and send the contextCreated event var ls: js.Local.Scope = undefined; (isolated_world.context orelse continue).localScope(&ls); defer ls.deinit(); bc.inspector_session.inspector.contextCreated( &ls.local, isolated_world.name, "://", aux_json, false, ); } // frameNavigated event try cdp.sendEvent("Page.frameNavigated", .{ .type = "Navigation", .frame = Frame{ .id = frame_id, .url = event.url, .loaderId = loader_id, .securityOrigin = bc.security_origin, .secureContextType = bc.secure_context_type, }, }, .{ .session_id = session_id }); // The DOM.documentUpdated event must be send after the frameNavigated one. // chromedp client expects to receive the events is this order. // see https://github.com/chromedp/chromedp/issues/1558 try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id }); // domContentEventFired event // TODO: partially hard coded try cdp.sendEvent( "Page.domContentEventFired", .{ .timestamp = timestamp }, .{ .session_id = session_id }, ); // lifecycle DOMContentLoaded event // TODO: partially hard coded if (bc.page_life_cycle_events) { try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ .timestamp = timestamp, .name = "DOMContentLoaded", .frameId = frame_id, .loaderId = loader_id, }, .{ .session_id = session_id }); } // loadEventFired event try cdp.sendEvent( "Page.loadEventFired", .{ .timestamp = timestamp }, .{ .session_id = session_id }, ); // lifecycle DOMContentLoaded event if (bc.page_life_cycle_events) { try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ .timestamp = timestamp, .name = "load", .frameId = frame_id, .loaderId = loader_id, }, .{ .session_id = session_id }); } // frameStoppedLoading return cdp.sendEvent("Page.frameStoppedLoading", .{ .frameId = frame_id, }, .{ .session_id = session_id }); } pub fn pageNetworkIdle(bc: anytype, event: *const Notification.PageNetworkIdle) !void { return sendPageLifecycle(bc, "networkIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.req_id)); } pub fn pageNetworkAlmostIdle(bc: anytype, event: *const Notification.PageNetworkAlmostIdle) !void { return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.req_id)); } fn sendPageLifecycle(bc: anytype, name: []const u8, timestamp: u64, frame_id: []const u8, loader_id: []const u8) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. const session_id = bc.session_id orelse return; return bc.cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ .name = name, .frameId = frame_id, .loaderId = loader_id, .timestamp = timestamp, }, .{ .session_id = session_id }); } const LifecycleEvent = struct { frameId: []const u8, loaderId: ?[]const u8, name: []const u8, timestamp: u64, }; const Viewport = struct { x: f64, y: f64, width: f64, height: f64, scale: f64, }; fn base64Encode(comptime input: []const u8) [std.base64.standard.Encoder.calcSize(input.len)]u8 { const encoder = std.base64.standard.Encoder; var buf: [encoder.calcSize(input.len)]u8 = undefined; _ = encoder.encode(&buf, input); return buf; } fn captureScreenshot(cmd: anytype) !void { const Params = struct { format: ?[]const u8 = "png", quality: ?u8 = null, clip: ?Viewport = null, fromSurface: ?bool = false, captureBeyondViewport: ?bool = false, optimizeForSpeed: ?bool = false, }; const params = try cmd.params(Params) orelse Params{}; const format = params.format orelse "png"; if (!std.mem.eql(u8, format, "png")) { log.warn(.not_implemented, "Page.captureScreenshot params", .{ .format = format }); return cmd.sendError(-32000, "unsupported screenshot format.", .{}); } if (params.quality != null) { log.warn(.not_implemented, "Page.captureScreenshot params", .{ .quality = params.quality }); } if (params.clip != null) { log.warn(.not_implemented, "Page.captureScreenshot params", .{ .clip = params.clip }); } if (params.fromSurface orelse false or params.captureBeyondViewport orelse false or params.optimizeForSpeed orelse false) { log.warn(.not_implemented, "Page.captureScreenshot params", .{ .fromSurface = params.fromSurface, .captureBeyondViewport = params.captureBeyondViewport, .optimizeForSpeed = params.optimizeForSpeed, }); } return cmd.sendResult(.{ .data = base64Encode(screenshot_png), }, .{}); } fn getLayoutMetrics(cmd: anytype) !void { const width = 1920; const height = 1080; return cmd.sendResult(.{ .layoutViewport = .{ .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, }, .visualViewport = .{ .offsetX = 0, .offsetY = 0, .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, .scale = 1, .zoom = 1, }, .contentSize = .{ .x = 0, .y = 0, .width = width, .height = height, }, .cssLayoutViewport = .{ .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, }, .cssVisualViewport = .{ .offsetX = 0, .offsetY = 0, .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, .scale = 1, .zoom = 1, }, .cssContentSize = .{ .x = 0, .y = 0, .width = width, .height = height, }, }, .{}); } const testing = @import("../testing.zig"); test "cdp.page: getFrameTree" { var ctx = testing.context(); defer ctx.deinit(); { try ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); } const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); { try ctx.processMessage(.{ .id = 11, .method = "Page.getFrameTree" }); try ctx.expectSentResult(.{ .frameTree = .{ .frame = .{ .id = "FID-000000000X", .loaderId = "LID-0000000001", .url = "http://127.0.0.1:9582/src/browser/tests/hi.html", .domainAndRegistry = "", .securityOrigin = bc.security_origin, .mimeType = "text/html", .adFrameStatus = .{ .adFrameType = "none", }, .secureContextType = bc.secure_context_type, .crossOriginIsolatedContextType = "NotIsolated", .gatedAPIFeatures = [_][]const u8{}, }, }, }, .{ .id = 11 }); } } test "cdp.page: captureScreenshot" { const LogFilter = @import("../../testing.zig").LogFilter; const filter: LogFilter = .init(&.{.not_implemented}); defer filter.deinit(); var ctx = testing.context(); defer ctx.deinit(); { try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } }); try ctx.expectSentError(-32000, "unsupported screenshot format.", .{ .id = 10 }); } { try ctx.processMessage(.{ .id = 11, .method = "Page.captureScreenshot" }); try ctx.expectSentResult(.{ .data = base64Encode(screenshot_png), }, .{ .id = 11 }); } } test "cdp.page: getLayoutMetrics" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); const width = 1920; const height = 1080; try ctx.processMessage(.{ .id = 12, .method = "Page.getLayoutMetrics" }); try ctx.expectSentResult(.{ .layoutViewport = .{ .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, }, .visualViewport = .{ .offsetX = 0, .offsetY = 0, .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, .scale = 1, .zoom = 1, }, .contentSize = .{ .x = 0, .y = 0, .width = width, .height = height, }, .cssLayoutViewport = .{ .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, }, .cssVisualViewport = .{ .offsetX = 0, .offsetY = 0, .pageX = 0, .pageY = 0, .clientWidth = width, .clientHeight = height, .scale = 1, .zoom = 1, }, .cssContentSize = .{ .x = 0, .y = 0, .width = width, .height = height, }, }, .{ .id = 12 }); } ================================================ FILE: src/cdp/domains/performance.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, disable, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), .disable => return cmd.sendResult(null, .{}), } } ================================================ FILE: src/cdp/domains/runtime.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, runIfWaitingForDebugger, evaluate, addBinding, callFunctionOn, releaseObject, getProperties, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .runIfWaitingForDebugger => return cmd.sendResult(null, .{}), else => return sendInspector(cmd, action), } } fn sendInspector(cmd: anytype, action: anytype) !void { // save script in file at debug mode if (builtin.mode == .Debug) { try logInspector(cmd, action); } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; // the result to return is handled directly by the inspector. bc.callInspector(cmd.input.json); } fn logInspector(cmd: anytype, action: anytype) !void { const script = switch (action) { .evaluate => blk: { const params = (try cmd.params(struct { expression: []const u8, // contextId: ?u8 = null, // returnByValue: ?bool = null, // awaitPromise: ?bool = null, // userGesture: ?bool = null, })) orelse return error.InvalidParams; break :blk params.expression; }, .callFunctionOn => blk: { const params = (try cmd.params(struct { functionDeclaration: []const u8, // objectId: ?[]const u8 = null, // executionContextId: ?u8 = null, // arguments: ?[]struct { // value: ?[]const u8 = null, // objectId: ?[]const u8 = null, // } = null, // returnByValue: ?bool = null, // awaitPromise: ?bool = null, // userGesture: ?bool = null, })) orelse return error.InvalidParams; break :blk params.functionDeclaration; }, else => return, }; const id = cmd.input.id orelse return error.RequiredId; const name = try std.fmt.allocPrint(cmd.arena, "id_{d}.js", .{id}); var dir = try std.fs.cwd().makeOpenPath(".zig-cache/tmp", .{}); defer dir.close(); const f = try dir.createFile(name, .{}); defer f.close(); try f.writeAll(script); } ================================================ FILE: src/cdp/domains/security.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, disable, setIgnoreCertificateErrors, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), .disable => return cmd.sendResult(null, .{}), .setIgnoreCertificateErrors => return setIgnoreCertificateErrors(cmd), } } fn setIgnoreCertificateErrors(cmd: anytype) !void { const params = (try cmd.params(struct { ignore: bool, })) orelse return error.InvalidParams; try cmd.cdp.browser.http_client.setTlsVerify(!params.ignore); return cmd.sendResult(null, .{}); } const testing = @import("../testing.zig"); test "cdp.Security: setIgnoreCertificateErrors" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-9" }); try ctx.processMessage(.{ .id = 8, .method = "Security.setIgnoreCertificateErrors", .params = .{ .ignore = true }, }); try ctx.expectSentResult(null, .{ .id = 8 }); try ctx.processMessage(.{ .id = 9, .method = "Security.setIgnoreCertificateErrors", .params = .{ .ignore = false }, }); try ctx.expectSentResult(null, .{ .id = 9 }); } ================================================ FILE: src/cdp/domains/storage.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const log = @import("../../log.zig"); const URL = @import("../../browser/URL.zig"); const Cookie = @import("../../browser/webapi/storage/storage.zig").Cookie; const CookieJar = Cookie.Jar; pub const PreparedUri = Cookie.PreparedUri; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { clearCookies, setCookies, getCookies, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .clearCookies => return clearCookies(cmd), .getCookies => return getCookies(cmd), .setCookies => return setCookies(cmd), } } const BrowserContextParam = struct { browserContextId: ?[]const u8 = null }; fn clearCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{}; if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) { return error.UnknownBrowserContextId; } } bc.session.cookie_jar.clearRetainingCapacity(); return cmd.sendResult(null, .{}); } fn getCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{}; if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) { return error.UnknownBrowserContextId; } } bc.session.cookie_jar.removeExpired(null); const writer = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items }; try cmd.sendResult(.{ .cookies = writer }, .{}); } fn setCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { cookies: []const CdpCookie, browserContextId: ?[]const u8 = null, })) orelse return error.InvalidParams; if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) { return error.UnknownBrowserContextId; } } for (params.cookies) |param| { try setCdpCookie(&bc.session.cookie_jar, param); } try cmd.sendResult(null, .{}); } pub const SameSite = enum { Strict, Lax, None, }; pub const CookiePriority = enum { Low, Medium, High, }; pub const CookieSourceScheme = enum { Unset, NonSecure, Secure, }; pub const CookiePartitionKey = struct { topLevelSite: []const u8, hasCrossSiteAncestor: bool, }; pub const CdpCookie = struct { name: []const u8, value: []const u8, url: ?[:0]const u8 = null, domain: ?[]const u8 = null, path: ?[:0]const u8 = null, secure: ?bool = null, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies expires: ?f64 = null, // -1? says google priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 sameParty: ?bool = null, sourceScheme: ?CookieSourceScheme = null, // sourcePort: Temporary ability and it will be removed from CDP partitionKey: ?CookiePartitionKey = null, }; pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { // Silently ignore partitionKey since we don't support partitioned cookies (CHIPS). // This allows Puppeteer's page.setCookie() to work, which may send cookies with // partitionKey as part of its cookie-setting workflow. if (param.partitionKey != null) { log.warn(.not_implemented, "partition key", .{ .src = "setCdpCookie" }); } // Still reject unsupported features if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null) { return error.NotImplemented; } var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator); errdefer arena.deinit(); const a = arena.allocator(); // NOTE: The param.url can affect the default domain, (NOT path), secure, source port, and source scheme. const domain = try Cookie.parseDomain(a, param.url, param.domain); const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path); const secure = if (param.secure) |s| s else if (param.url) |url| URL.isHTTPS(url) else false; const cookie = Cookie{ .arena = arena, .name = try a.dupe(u8, param.name), .value = try a.dupe(u8, param.value), .path = path, .domain = domain, .expires = param.expires, .secure = secure, .http_only = param.httpOnly, .same_site = switch (param.sameSite) { .Strict => .strict, .Lax => .lax, .None => .none, }, }; try cookie_jar.add(cookie, std.time.timestamp()); } pub const CookieWriter = struct { cookies: []const Cookie, urls: ?[]const PreparedUri = null, pub fn jsonStringify(self: *const CookieWriter, w: anytype) !void { self.writeCookies(w) catch |err| { // The only error our jsonStringify method can return is @TypeOf(w).Error. log.err(.cdp, "json stringify", .{ .err = err }); return error.WriteFailed; }; } fn writeCookies(self: CookieWriter, w: anytype) !void { try w.beginArray(); if (self.urls) |urls| { for (self.cookies) |*cookie| { for (urls) |*url| { if (cookie.appliesTo(url, true, true, true)) { // TBD same_site, should we compare to the pages url? try writeCookie(cookie, w); break; } } } } else { for (self.cookies) |*cookie| { try writeCookie(cookie, w); } } try w.endArray(); } }; pub fn writeCookie(cookie: *const Cookie, w: anytype) !void { try w.beginObject(); { try w.objectField("name"); try w.write(cookie.name); try w.objectField("value"); try w.write(cookie.value); try w.objectField("domain"); try w.write(cookie.domain); // Should we hide a leading dot? try w.objectField("path"); try w.write(cookie.path); try w.objectField("expires"); try w.write(cookie.expires orelse -1); try w.objectField("size"); try w.write(cookie.name.len + cookie.value.len); try w.objectField("httpOnly"); try w.write(cookie.http_only); try w.objectField("secure"); try w.write(cookie.secure); try w.objectField("session"); try w.write(cookie.expires == null); try w.objectField("sameSite"); switch (cookie.same_site) { .none => try w.write("None"), .lax => try w.write("Lax"), .strict => try w.write("Strict"), } // TODO experimentals } try w.endObject(); } const testing = @import("../testing.zig"); test "cdp.Storage: cookies" { var ctx = testing.context(); defer ctx.deinit(); _ = try ctx.loadBrowserContext(.{ .id = "BID-S" }); // Initially empty try ctx.processMessage(.{ .id = 3, .method = "Storage.getCookies", .params = .{ .browserContextId = "BID-S" }, }); try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 }); // Has cookies after setting them try ctx.processMessage(.{ .id = 4, .method = "Storage.setCookies", .params = .{ .cookies = &[_]CdpCookie{ .{ .name = "test", .value = "value", .domain = "example.com", .path = "/mango" }, .{ .name = "test2", .value = "value2", .url = "https://car.example.com/pancakes" }, }, .browserContextId = "BID-S", }, }); try ctx.expectSentResult(null, .{ .id = 4 }); try ctx.processMessage(.{ .id = 5, .method = "Storage.getCookies", .params = .{ .browserContextId = "BID-S" }, }); try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{ .{ .name = "test", .value = "value", .domain = ".example.com", .path = "/mango", .size = 9 }, .{ .name = "test2", .value = "value2", .domain = "car.example.com", .path = "/", .size = 11, .secure = true }, // No Pancakes! }, }, .{ .id = 5 }); // Empty after clearing cookies try ctx.processMessage(.{ .id = 6, .method = "Storage.clearCookies", .params = .{ .browserContextId = "BID-S" }, }); try ctx.expectSentResult(null, .{ .id = 6 }); try ctx.processMessage(.{ .id = 7, .method = "Storage.getCookies", .params = .{ .browserContextId = "BID-S" }, }); try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 7 }); } pub const ResCookie = struct { name: []const u8, value: []const u8, domain: []const u8, path: []const u8 = "/", expires: f64 = -1, size: usize = 0, httpOnly: bool = false, secure: bool = false, sameSite: []const u8 = "None", }; ================================================ FILE: src/cdp/domains/target.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const id = @import("../id.zig"); const log = @import("../../log.zig"); const URL = @import("../../browser/URL.zig"); const js = @import("../../browser/js/js.zig"); // TODO: hard coded IDs const LOADER_ID = "LOADERID42AA389647D702B4D805F49A"; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { getTargets, attachToTarget, attachToBrowserTarget, closeTarget, createBrowserContext, createTarget, detachFromTarget, disposeBrowserContext, getBrowserContexts, getTargetInfo, sendMessageToTarget, setAutoAttach, setDiscoverTargets, activateTarget, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getTargets => return getTargets(cmd), .attachToTarget => return attachToTarget(cmd), .attachToBrowserTarget => return attachToBrowserTarget(cmd), .closeTarget => return closeTarget(cmd), .createBrowserContext => return createBrowserContext(cmd), .createTarget => return createTarget(cmd), .detachFromTarget => return detachFromTarget(cmd), .disposeBrowserContext => return disposeBrowserContext(cmd), .getBrowserContexts => return getBrowserContexts(cmd), .getTargetInfo => return getTargetInfo(cmd), .sendMessageToTarget => return sendMessageToTarget(cmd), .setAutoAttach => return setAutoAttach(cmd), .setDiscoverTargets => return setDiscoverTargets(cmd), .activateTarget => return cmd.sendResult(null, .{}), } } fn getTargets(cmd: anytype) !void { // If no context available, return an empty array. const bc = cmd.browser_context orelse { return cmd.sendResult(.{ .targetInfos = [_]TargetInfo{}, }, .{ .include_session_id = false }); }; const target_id = &(bc.target_id orelse { return cmd.sendResult(.{ .targetInfos = [_]TargetInfo{}, }, .{ .include_session_id = false }); }); return cmd.sendResult(.{ .targetInfos = [_]TargetInfo{.{ .targetId = target_id, .type = "page", .title = bc.getTitle() orelse "", .url = bc.getURL() orelse "about:blank", .attached = true, .canAccessOpener = false, }}, }, .{ .include_session_id = false }); } fn getBrowserContexts(cmd: anytype) !void { var browser_context_ids: []const []const u8 = undefined; if (cmd.browser_context) |bc| { browser_context_ids = &.{bc.id}; } else { browser_context_ids = &.{}; } return cmd.sendResult(.{ .browserContextIds = browser_context_ids, }, .{ .include_session_id = false }); } fn createBrowserContext(cmd: anytype) !void { const params = try cmd.params(struct { disposeOnDetach: bool = false, proxyServer: ?[:0]const u8 = null, proxyBypassList: ?[]const u8 = null, originsWithUniversalNetworkAccess: ?[]const []const u8 = null, }); if (params) |p| { if (p.disposeOnDetach or p.proxyBypassList != null or p.originsWithUniversalNetworkAccess != null) { log.warn(.not_implemented, "Target.createBrowserContext", .{ .disposeOnDetach = p.disposeOnDetach, .has_proxyBypassList = p.proxyBypassList != null, .has_originsWithUniversalNetworkAccess = p.originsWithUniversalNetworkAccess != null }); } } const bc = cmd.createBrowserContext() catch |err| switch (err) { error.AlreadyExists => return cmd.sendError(-32000, "Cannot have more than one browser context at a time", .{}), else => return err, }; if (params) |p| { if (p.proxyServer) |proxy| { // For now the http client is not in the browser context so we assume there is just 1. try cmd.cdp.browser.http_client.changeProxy(proxy); bc.http_proxy_changed = true; } } return cmd.sendResult(.{ .browserContextId = bc.id, }, .{}); } fn disposeBrowserContext(cmd: anytype) !void { const params = (try cmd.params(struct { browserContextId: []const u8, })) orelse return error.InvalidParams; if (cmd.cdp.disposeBrowserContext(params.browserContextId) == false) { return cmd.sendError(-32602, "No browser context with the given id found", .{}); } try cmd.sendResult(null, .{}); } fn createTarget(cmd: anytype) !void { const params = (try cmd.params(struct { url: [:0]const u8 = "about:blank", // width: ?u64 = null, // height: ?u64 = null, browserContextId: ?[]const u8 = null, // enableBeginFrameControl: bool = false, // newWindow: bool = false, // background: bool = false, // forTab: ?bool = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse cmd.createBrowserContext() catch |err| switch (err) { error.AlreadyExists => unreachable, else => return err, }; if (bc.target_id != null) { return error.TargetAlreadyLoaded; } if (params.browserContextId) |param_browser_context_id| { if (std.mem.eql(u8, param_browser_context_id, bc.id) == false) { return error.UnknownBrowserContextId; } } // if target_id is null, we should never have a page lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{}); // if target_id is null, we should never have a session_id lp.assert(bc.session_id == null, "CDP.target.createTarget not null session_id", .{}); const page = try bc.session.createPage(); // the target_id == the frame_id of the "root" page const frame_id = id.toFrameId(page._frame_id); bc.target_id = frame_id; const target_id = &bc.target_id.?; { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id}); bc.inspector_session.inspector.contextCreated( &ls.local, "", "", // @ZIGDOM // try page.origin(arena), aux_data, true, ); } // change CDP state bc.security_origin = "://"; bc.secure_context_type = "InsecureScheme"; // send targetCreated event // TODO: should this only be sent when Target.setDiscoverTargets // has been enabled? try cmd.sendEvent("Target.targetCreated", .{ .targetInfo = TargetInfo{ .attached = false, .targetId = target_id, .title = "", .browserContextId = bc.id, .url = "about:blank", }, }, .{}); // attach to the target only if auto attach is set. if (cmd.cdp.target_auto_attach) { try doAttachtoTarget(cmd, target_id); } if (!std.mem.eql(u8, "about:blank", params.url)) { const encoded_url = try URL.ensureEncoded(page.call_arena, params.url); try page.navigate( encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } }, ); } try cmd.sendResult(.{ .targetId = target_id, }, .{}); } fn attachToTarget(cmd: anytype) !void { const params = (try cmd.params(struct { targetId: []const u8, flatten: bool = true, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const target_id = &(bc.target_id orelse return error.TargetNotLoaded); if (std.mem.eql(u8, target_id, params.targetId) == false) { return error.UnknownTargetId; } try doAttachtoTarget(cmd, target_id); return cmd.sendResult(.{ .sessionId = bc.session_id }, .{}); } fn attachToBrowserTarget(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next(); try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ .sessionId = session_id, .targetInfo = TargetInfo{ .targetId = bc.id, // We use the browser context is as browser's target id. .title = "", .url = "", .type = "browser", // Chrome doesn't send a browserContextId in this case. .browserContextId = null, }, }, .{}); bc.session_id = session_id; return cmd.sendResult(.{ .sessionId = bc.session_id }, .{}); } fn closeTarget(cmd: anytype) !void { const params = (try cmd.params(struct { targetId: []const u8, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const target_id = &(bc.target_id orelse return error.TargetNotLoaded); if (std.mem.eql(u8, target_id, params.targetId) == false) { return error.UnknownTargetId; } // can't be null if we have a target_id lp.assert(bc.session.page != null, "CDP.target.closeTarget null page", .{}); try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); // could be null, created but never attached if (bc.session_id) |session_id| { // Inspector.detached event try cmd.sendEvent("Inspector.detached", .{ .reason = "Render process gone.", }, .{ .session_id = session_id }); // detachedFromTarget event try cmd.sendEvent("Target.detachedFromTarget", .{ .targetId = target_id, .sessionId = session_id, .reason = "Render process gone.", }, .{}); bc.session_id = null; } bc.session.removePage(); for (bc.isolated_worlds.items) |world| { world.deinit(); } bc.isolated_worlds.clearRetainingCapacity(); bc.target_id = null; } fn getTargetInfo(cmd: anytype) !void { const Params = struct { targetId: ?[]const u8 = null, }; const params = (try cmd.params(Params)) orelse Params{}; if (params.targetId) |param_target_id| { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const target_id = &(bc.target_id orelse return error.TargetNotLoaded); if (std.mem.eql(u8, target_id, param_target_id) == false) { return error.UnknownTargetId; } return cmd.sendResult(.{ .targetInfo = TargetInfo{ .targetId = target_id, .type = "page", .title = bc.getTitle() orelse "", .url = bc.getURL() orelse "about:blank", .attached = true, .canAccessOpener = false, }, }, .{ .include_session_id = false }); } return cmd.sendResult(.{ .targetInfo = TargetInfo{ .targetId = "TID-STARTUP", .type = "browser", .title = "", .url = "about:blank", .attached = true, .canAccessOpener = false, }, }, .{ .include_session_id = false }); } fn sendMessageToTarget(cmd: anytype) !void { const params = (try cmd.params(struct { message: []const u8, sessionId: []const u8, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; if (bc.target_id == null) { return error.TargetNotLoaded; } lp.assert(bc.session_id != null, "CDP.target.sendMessageToTarget null session_id", .{}); if (std.mem.eql(u8, bc.session_id.?, params.sessionId) == false) { // Is this right? Is the params.sessionId meant to be the active // sessionId? What else could it be? We have no other session_id. return error.UnknownSessionId; } const Capture = struct { aw: std.Io.Writer.Allocating, pub fn sendJSON(self: *@This(), message: anytype) !void { return std.json.Stringify.value(message, .{ .emit_null_optional_fields = false, }, &self.aw.writer); } }; var capture = Capture{ .aw = .init(cmd.arena), }; cmd.cdp.dispatch(cmd.arena, &capture, params.message) catch |err| { log.err(.cdp, "internal dispatch error", .{ .err = err, .id = cmd.input.id, .message = params.message }); return err; }; try cmd.sendEvent("Target.receivedMessageFromTarget", .{ .message = capture.aw.written(), .sessionId = params.sessionId, }, .{}); } fn detachFromTarget(cmd: anytype) !void { // TODO check if sessionId/targetId match. // const params = (try cmd.params(struct { // sessionId: ?[]const u8, // targetId: ?[]const u8, // })) orelse return error.InvalidParams; if (cmd.browser_context) |bc| { bc.session_id = null; // TODO should we send a Target.detachedFromTarget event? } return cmd.sendResult(null, .{}); } // TODO: noop method fn setDiscoverTargets(cmd: anytype) !void { return cmd.sendResult(null, .{}); } fn setAutoAttach(cmd: anytype) !void { const params = (try cmd.params(struct { autoAttach: bool, waitForDebuggerOnStart: bool, flatten: bool = true, // filter: ?[]TargetFilter = null, })) orelse return error.InvalidParams; // set a flag to send Target.attachedToTarget events cmd.cdp.target_auto_attach = params.autoAttach; if (cmd.cdp.target_auto_attach == false) { // detach from all currently attached targets. if (cmd.browser_context) |bc| { bc.session_id = null; // TODO should we send a Target.detachedFromTarget event? } try cmd.sendResult(null, .{}); return; } // autoAttach is set to true, we must attach to all existing targets. if (cmd.browser_context) |bc| { if (bc.target_id == null) { if (bc.session.currentPage()) |page| { // the target_id == the frame_id of the "root" page bc.target_id = id.toFrameId(page._frame_id); try doAttachtoTarget(cmd, &bc.target_id.?); } } try cmd.sendResult(null, .{}); return; } // This is a hack. Puppeteer, and probably others, expect the Browser to // automatically started creating targets. Things like an empty tab, or // a blank page. And they block until this happens. So we send an event // telling them that they've been attached to our Broswer. Hopefully, the // first thing they'll do is create a real BrowserContext and progress from // there. // This hack requires the main cdp dispatch handler to special case // messages from this "STARTUP" session. try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ .sessionId = "STARTUP", .targetInfo = TargetInfo{ .type = "page", .targetId = "TID-STARTUP", .title = "", .url = "about:blank", .browserContextId = "BID-STARTUP", }, }, .{}); try cmd.sendResult(null, .{}); } fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void { const bc = cmd.browser_context.?; const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next(); if (bc.session_id == null) { // extra_headers should not be kept on a new page or tab, // currently we have only 1 page, we clear it just in case bc.extra_headers.clearRetainingCapacity(); } try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ .sessionId = session_id, .targetInfo = TargetInfo{ .targetId = target_id, .title = bc.getTitle() orelse "", .url = bc.getURL() orelse "about:blank", .browserContextId = bc.id, }, }, .{ .session_id = bc.session_id }); bc.session_id = session_id; } const AttachToTarget = struct { sessionId: []const u8, targetInfo: TargetInfo, waitingForDebugger: bool = false, }; const TargetInfo = struct { url: []const u8, title: []const u8, targetId: []const u8, attached: bool = true, type: []const u8 = "page", canAccessOpener: bool = false, browserContextId: ?[]const u8 = null, }; const testing = @import("../testing.zig"); test "cdp.target: getBrowserContexts" { var ctx = testing.context(); defer ctx.deinit(); // { // // no browser context // try ctx.processMessage(.{.id = 4, .method = "Target.getBrowserContexts"}); // try ctx.expectSentResult(.{ // .browserContextIds = &.{}, // }, .{ .id = 4, .session_id = null }); // } { // with a browser context _ = try ctx.loadBrowserContext(.{ .id = "BID-X" }); try ctx.processMessage(.{ .id = 5, .method = "Target.getBrowserContexts" }); try ctx.expectSentResult(.{ .browserContextIds = &.{"BID-X"}, }, .{ .id = 5, .session_id = null }); } } test "cdp.target: createBrowserContext" { var ctx = testing.context(); defer ctx.deinit(); { try ctx.processMessage(.{ .id = 4, .method = "Target.createBrowserContext" }); try ctx.expectSentResult(.{ .browserContextId = ctx.cdp().browser_context.?.id, }, .{ .id = 4, .session_id = null }); } { // we already have one now try ctx.processMessage(.{ .id = 5, .method = "Target.createBrowserContext" }); try ctx.expectSentError(-32000, "Cannot have more than one browser context at a time", .{ .id = 5 }); } } test "cdp.target: disposeBrowserContext" { var ctx = testing.context(); defer ctx.deinit(); { try ctx.processMessage(.{ .id = 7, .method = "Target.disposeBrowserContext" }); try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 7 }); } { try ctx.processMessage(.{ .id = 8, .method = "Target.disposeBrowserContext", .params = .{ .browserContextId = "BID-10" }, }); try ctx.expectSentError(-32602, "No browser context with the given id found", .{ .id = 8 }); } { _ = try ctx.loadBrowserContext(.{ .id = "BID-20" }); try ctx.processMessage(.{ .id = 9, .method = "Target.disposeBrowserContext", .params = .{ .browserContextId = "BID-20" }, }); try ctx.expectSentResult(null, .{ .id = 9 }); try testing.expectEqual(null, ctx.cdp().browser_context); } } test "cdp.target: createTarget" { { var ctx = testing.context(); defer ctx.deinit(); try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); // should create a browser context const bc = ctx.cdp().browser_context.?; try ctx.expectSentEvent("Target.targetCreated", .{ .targetInfo = .{ .url = "about:blank", .title = "", .attached = false, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{}); } { var ctx = testing.context(); defer ctx.deinit(); // active auto attach to get the Target.attachedToTarget event. try ctx.processMessage(.{ .id = 9, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } }); try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); // should create a browser context const bc = ctx.cdp().browser_context.?; try ctx.expectSentEvent("Target.targetCreated", .{ .targetInfo = .{ .url = "about:blank", .title = "", .attached = false, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{}); try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = bc.session_id.?, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{}); } var ctx = testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); { try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-8" } }); try ctx.expectSentError(-31998, "UnknownBrowserContextId", .{ .id = 10 }); } { try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); try testing.expectEqual(true, bc.target_id != null); try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); try ctx.expectSentEvent("Target.targetCreated", .{ .targetInfo = .{ .url = "about:blank", .title = "", .attached = false, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); } } test "cdp.target: closeTarget" { var ctx = testing.context(); defer ctx.deinit(); { try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "X" } }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); } const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); { try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } }); try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 }); } // pretend we createdTarget first _ = try bc.session.createPage(); bc.target_id = "TID-000000000A".*; { try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } }); try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 }); } { try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } }); try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 }); try testing.expectEqual(null, bc.session.page); try testing.expectEqual(null, bc.target_id); } } test "cdp.target: attachToTarget" { var ctx = testing.context(); defer ctx.deinit(); { try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "X" } }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); } const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); { try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } }); try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 }); } // pretend we createdTarget first _ = try bc.session.createPage(); bc.target_id = "TID-000000000B".*; { try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } }); try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 }); } { try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } }); const session_id = bc.session_id.?; try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = session_id, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); } } test "cdp.target: getTargetInfo" { var ctx = testing.context(); defer ctx.deinit(); { try ctx.processMessage(.{ .id = 9, .method = "Target.getTargetInfo" }); try ctx.expectSentResult(.{ .targetInfo = .{ .type = "browser", .title = "", .url = "about:blank", .attached = true, .canAccessOpener = false, }, }, .{ .id = 9 }); } { try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "X" } }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); } const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); { try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } }); try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 }); } // pretend we createdTarget first _ = try bc.session.createPage(); bc.target_id = "TID-000000000C".*; { try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } }); try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 }); } { try ctx.processMessage(.{ .id = 11, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-000000000C" } }); try ctx.expectSentResult(.{ .targetInfo = .{ .targetId = "TID-000000000C", .type = "page", .title = "", .url = "about:blank", .attached = true, .canAccessOpener = false, }, }, .{ .id = 11 }); } } test "cdp.target: issue#474: attach to just created target" { var ctx = testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); { try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); try testing.expectEqual(true, bc.target_id != null); try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); const session_id = bc.session_id.?; try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); } } test "cdp.target: detachFromTarget" { var ctx = testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); { try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); try testing.expectEqual(true, bc.target_id != null); try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 11 }); try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .targetId = bc.target_id.? } }); try testing.expectEqual(null, bc.session_id); try ctx.expectSentResult(null, .{ .id = 12 }); try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 13 }); } } ================================================ FILE: src/cdp/id.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const IS_DEBUG = @import("builtin").mode == .Debug; pub fn toPageId(comptime id_type: enum { frame_id, loader_id }, input: []const u8) !u32 { const err = switch (comptime id_type) { .frame_id => error.InvalidFrameId, .loader_id => error.InvalidLoaderId, }; if (input.len < 4) { return err; } return std.fmt.parseInt(u32, input[4..], 10) catch err; } pub fn toFrameId(page_id: u32) [14]u8 { var buf: [14]u8 = undefined; _ = std.fmt.bufPrint(&buf, "FID-{d:0>10}", .{page_id}) catch unreachable; return buf; } pub fn toLoaderId(page_id: u32) [14]u8 { var buf: [14]u8 = undefined; _ = std.fmt.bufPrint(&buf, "LID-{d:0>10}", .{page_id}) catch unreachable; return buf; } pub fn toRequestId(page_id: u32) [14]u8 { var buf: [14]u8 = undefined; _ = std.fmt.bufPrint(&buf, "REQ-{d:0>10}", .{page_id}) catch unreachable; return buf; } pub fn toInterceptId(page_id: u32) [14]u8 { var buf: [14]u8 = undefined; _ = std.fmt.bufPrint(&buf, "INT-{d:0>10}", .{page_id}) catch unreachable; return buf; } // Generates incrementing prefixed integers, i.e. CTX-1, CTX-2, CTX-3. // Wraps to 0 on overflow. // Many caveats for using this: // - Not thread-safe. // - Information leaking // - The slice returned by next() is only valid: // - while incrementor is valid // - until the next call to next() // On the positive, it's zero allocation pub fn Incrementing(comptime T: type, comptime prefix: []const u8) type { // +1 for the '-' separator const NUMERIC_START = prefix.len + 1; const MAX_BYTES = NUMERIC_START + switch (T) { u8 => 3, u16 => 5, u32 => 10, u64 => 20, else => @compileError("Incrementing must be given an unsigned int type, got: " ++ @typeName(T)), }; const buffer = blk: { var b = [_]u8{0} ** MAX_BYTES; @memcpy(b[0..prefix.len], prefix); b[prefix.len] = '-'; break :blk b; }; const PrefixIntType = @Type(.{ .int = .{ .bits = NUMERIC_START * 8, .signedness = .unsigned, } }); const PREFIX_INT_CODE: PrefixIntType = @bitCast(buffer[0..NUMERIC_START].*); return struct { counter: T = 0, buffer: [MAX_BYTES]u8 = buffer, const Self = @This(); pub fn next(self: *Self) []const u8 { const counter = self.counter; const n = counter +% 1; defer self.counter = n; const size = std.fmt.printInt(self.buffer[NUMERIC_START..], n, 10, .lower, .{}); return self.buffer[0 .. NUMERIC_START + size]; } // extracts the numeric portion from an ID pub fn parse(str: []const u8) !T { if (str.len <= NUMERIC_START) { return error.InvalidId; } if (@as(PrefixIntType, @bitCast(str[0..NUMERIC_START].*)) != PREFIX_INT_CODE) { return error.InvalidId; } return std.fmt.parseInt(T, str[NUMERIC_START..], 10) catch { return error.InvalidId; }; } }; } const testing = @import("../testing.zig"); test "id: Incrementing.next" { var id = Incrementing(u16, "IDX"){}; try testing.expectEqual("IDX-1", id.next()); try testing.expectEqual("IDX-2", id.next()); try testing.expectEqual("IDX-3", id.next()); // force a wrap id.counter = 65533; try testing.expectEqual("IDX-65534", id.next()); try testing.expectEqual("IDX-65535", id.next()); try testing.expectEqual("IDX-0", id.next()); } test "id: Incrementing.parse" { const ReqId = Incrementing(u32, "REQ"); try testing.expectError(error.InvalidId, ReqId.parse("")); try testing.expectError(error.InvalidId, ReqId.parse("R")); try testing.expectError(error.InvalidId, ReqId.parse("RE")); try testing.expectError(error.InvalidId, ReqId.parse("REQ")); try testing.expectError(error.InvalidId, ReqId.parse("REQ-")); try testing.expectError(error.InvalidId, ReqId.parse("REQ--1")); try testing.expectError(error.InvalidId, ReqId.parse("REQ--")); try testing.expectError(error.InvalidId, ReqId.parse("REQ-Nope")); try testing.expectError(error.InvalidId, ReqId.parse("REQ-4294967296")); try testing.expectEqual(0, try ReqId.parse("REQ-0")); try testing.expectEqual(99, try ReqId.parse("REQ-99")); try testing.expectEqual(4294967295, try ReqId.parse("REQ-4294967295")); } test "id: toPageId" { try testing.expectEqual(0, toPageId(.frame_id, "FID-0")); try testing.expectEqual(0, toPageId(.loader_id, "LID-0")); try testing.expectEqual(4294967295, toPageId(.frame_id, "FID-4294967295")); try testing.expectEqual(4294967295, toPageId(.loader_id, "LID-4294967295")); try testing.expectError(error.InvalidFrameId, toPageId(.frame_id, "")); try testing.expectError(error.InvalidLoaderId, toPageId(.loader_id, "LID-NOPE")); } test "id: toFrameId" { try testing.expectEqual("FID-0000000000", toFrameId(0)); try testing.expectEqual("FID-4294967295", toFrameId(4294967295)); } test "id: toLoaderId" { try testing.expectEqual("LID-0000000000", toLoaderId(0)); try testing.expectEqual("LID-4294967295", toLoaderId(4294967295)); } test "id: toRequestId" { try testing.expectEqual("REQ-0000000000", toRequestId(0)); try testing.expectEqual("REQ-4294967295", toRequestId(4294967295)); } test "id: toInterceptId" { try testing.expectEqual("INT-0000000000", toInterceptId(0)); try testing.expectEqual("INT-4294967295", toInterceptId(4294967295)); } ================================================ FILE: src/cdp/testing.zig ================================================ // Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const json = std.json; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const Testing = @This(); const main = @import("cdp.zig"); const base = @import("../testing.zig"); pub const allocator = base.allocator; pub const expectJson = base.expectJson; pub const expect = std.testing.expect; pub const expectEqual = base.expectEqual; pub const expectError = base.expectError; pub const expectEqualSlices = base.expectEqualSlices; pub const pageTest = base.pageTest; pub const newString = base.newString; const Client = struct { allocator: Allocator, send_arena: ArenaAllocator, sent: std.ArrayList(json.Value) = .{}, serialized: std.ArrayList([]const u8) = .{}, fn init(alloc: Allocator) Client { return .{ .allocator = alloc, .send_arena = ArenaAllocator.init(alloc), }; } pub fn sendAllocator(self: *Client) Allocator { return self.send_arena.allocator(); } pub fn sendJSON(self: *Client, message: anytype, opts: json.Stringify.Options) !void { var opts_copy = opts; opts_copy.whitespace = .indent_2; const serialized = try json.Stringify.valueAlloc(self.allocator, message, opts_copy); try self.serialized.append(self.allocator, serialized); const value = try json.parseFromSliceLeaky(json.Value, self.allocator, serialized, .{}); try self.sent.append(self.allocator, value); } pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void { const value = try json.parseFromSliceLeaky(json.Value, self.allocator, buf.items, .{}); try self.sent.append(self.allocator, value); } }; const TestCDP = main.CDPT(struct { pub const Client = *Testing.Client; }); const TestContext = struct { client: ?Client = null, cdp_: ?TestCDP = null, arena: ArenaAllocator, pub fn deinit(self: *TestContext) void { if (self.cdp_) |*c| { c.deinit(); } self.arena.deinit(); } pub fn cdp(self: *TestContext) *TestCDP { if (self.cdp_ == null) { self.client = Client.init(self.arena.allocator()); // Don't use the arena here. We want to detect leaks in CDP. // The arena is only for test-specific stuff self.cdp_ = TestCDP.init(base.test_app, base.test_http, &self.client.?) catch unreachable; } return &self.cdp_.?; } const BrowserContextOpts = struct { id: ?[]const u8 = null, target_id: ?[14]u8 = null, session_id: ?[]const u8 = null, url: ?[:0]const u8 = null, }; pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*main.BrowserContext(TestCDP) { var c = self.cdp(); if (c.browser_context) |bc| { _ = c.disposeBrowserContext(bc.id); } _ = try c.createBrowserContext(); var bc = &c.browser_context.?; if (opts.id) |id| { bc.id = id; } if (opts.target_id) |tid| { bc.target_id = tid; } if (opts.session_id) |sid| { bc.session_id = sid; } if (opts.url) |url| { if (bc.session_id == null) { bc.session_id = "SID-X"; } if (bc.target_id == null) { bc.target_id = "TID-000000000Z".*; } const page = try bc.session.createPage(); const full_url = try std.fmt.allocPrintSentinel( self.arena.allocator(), "http://127.0.0.1:9582/src/browser/tests/{s}", .{url}, 0, ); try page.navigate(full_url, .{}); _ = bc.session.wait(2000); } return bc; } pub fn processMessage(self: *TestContext, msg: anytype) !void { var json_message: []const u8 = undefined; if (@typeInfo(@TypeOf(msg)) != .pointer) { json_message = try std.json.Stringify.valueAlloc(self.arena.allocator(), msg, .{}); } else { // assume this is a string we want to send as-is, if it isn't, we'll // get a compile error, so no big deal. json_message = msg; } return self.cdp().processMessage(json_message); } pub fn expectSentCount(self: *TestContext, expected: usize) !void { try expectEqual(expected, self.client.?.sent.items.len); } const ExpectResultOpts = struct { id: ?usize = null, index: ?usize = null, session_id: ?[]const u8 = null, }; pub fn expectSentResult(self: *TestContext, expected: anytype, opts: ExpectResultOpts) !void { const expected_result = .{ .id = opts.id, .result = if (comptime @typeInfo(@TypeOf(expected)) == .null) struct {}{} else expected, .sessionId = opts.session_id, }; try self.expectSent(expected_result, .{ .index = opts.index }); } const ExpectEventOpts = struct { index: ?usize = null, session_id: ?[]const u8 = null, }; pub fn expectSentEvent(self: *TestContext, method: []const u8, params: anytype, opts: ExpectEventOpts) !void { const expected_event = .{ .method = method, .params = if (comptime @typeInfo(@TypeOf(params)) == .null) struct {}{} else params, .sessionId = opts.session_id, }; try self.expectSent(expected_event, .{ .index = opts.index }); } const ExpectErrorOpts = struct { id: ?usize = null, index: ?usize = null, }; pub fn expectSentError(self: *TestContext, code: i32, message: []const u8, opts: ExpectErrorOpts) !void { const expected_message = .{ .id = opts.id, .@"error" = .{ .code = code, .message = message }, }; try self.expectSent(expected_message, .{ .index = opts.index }); } const SentOpts = struct { index: ?usize = null, }; pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void { const serialized = try json.Stringify.valueAlloc(self.arena.allocator(), expected, .{ .whitespace = .indent_2, .emit_null_optional_fields = false, }); for (self.client.?.sent.items, 0..) |sent, i| { if (try compareExpectedToSent(serialized, sent) == false) { continue; } if (opts.index) |expected_index| { if (expected_index != i) { return error.ErrorAtWrongIndex; } } _ = self.client.?.sent.orderedRemove(i); _ = self.client.?.serialized.orderedRemove(i); return; } std.debug.print("Error not found. Expecting:\n{s}\n\nGot:\n", .{serialized}); for (self.client.?.serialized.items, 0..) |sent, i| { std.debug.print("#{d}\n{s}\n\n", .{ i, sent }); } return error.ErrorNotFound; } }; pub fn context() TestContext { return .{ .arena = ArenaAllocator.init(std.testing.allocator), }; } // Zig makes this hard. When sendJSON is called, we're sending an anytype. // We can't record that in an ArrayList(???), so we serialize it to JSON. // Now, ideally, we could just take our expected structure, serialize it to // json and check if the two are equal. // Except serializing to JSON isn't deterministic. // So we serialize the JSON then we deserialize to json.Value. And then we can // compare our anytype expectation with the json.Value that we captured fn compareExpectedToSent(expected: []const u8, actual: json.Value) !bool { const expected_value = try std.json.parseFromSlice(json.Value, std.testing.allocator, expected, .{}); defer expected_value.deinit(); return base.isEqualJson(expected_value.value, actual); } ================================================ FILE: src/crash_handler.zig ================================================ const std = @import("std"); const lp = @import("lightpanda"); const builtin = @import("builtin"); const IS_DEBUG = builtin.mode == .Debug; const abort = std.posix.abort; // tracks how deep within a panic we're panicling var panic_level: usize = 0; // Locked to avoid interleaving panic messages from multiple threads. var panic_mutex = std.Thread.Mutex{}; // overwrite's Zig default panic handler pub fn panic(msg: []const u8, _: ?*std.builtin.StackTrace, begin_addr: ?usize) noreturn { @branchHint(.cold); crash(msg, .{ .source = "global" }, begin_addr orelse @returnAddress()); } pub noinline fn crash( reason: []const u8, args: anytype, begin_addr: usize, ) noreturn { @branchHint(.cold); nosuspend switch (panic_level) { 0 => { panic_level = panic_level + 1; { panic_mutex.lock(); defer panic_mutex.unlock(); var writer_w = std.fs.File.stderr().writerStreaming(&.{}); const writer = &writer_w.interface; writer.writeAll( \\ \\Lightpanda has crashed. Please report the issue: \\https://github.com/lightpanda-io/browser/issues \\or let us know on discord: https://discord.gg/g24PtgD6 \\ ) catch abort(); writer.print("\nreason: {s}\n", .{reason}) catch abort(); writer.print("OS: {s}\n", .{@tagName(builtin.os.tag)}) catch abort(); writer.print("mode: {s}\n", .{@tagName(builtin.mode)}) catch abort(); writer.print("version: {s}\n", .{lp.build_config.git_commit}) catch abort(); inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| { writer.writeAll(f.name ++ ": ") catch break; @import("log.zig").writeValue(.pretty, @field(args, f.name), writer) catch abort(); writer.writeByte('\n') catch abort(); } std.debug.dumpCurrentStackTraceToWriter(begin_addr, writer) catch abort(); } report(reason, begin_addr, args) catch {}; }, 1 => { panic_level = 2; var stderr_w = std.fs.File.stderr().writerStreaming(&.{}); const stderr = &stderr_w.interface; stderr.writeAll("panicked during a panic. Aborting.\n") catch abort(); }, else => {}, }; abort(); } fn report(reason: []const u8, begin_addr: usize, args: anytype) !void { if (comptime IS_DEBUG) { return; } if (@import("telemetry/telemetry.zig").isDisabled()) { return; } var curl_path: [2048]u8 = undefined; const curl_path_len = curlPath(&curl_path) orelse return; var url_buffer: [4096]u8 = undefined; const url = blk: { var writer: std.Io.Writer = .fixed(&url_buffer); try writer.print("https://crash.lightpanda.io/c?v={s}&r=", .{lp.build_config.git_commit}); for (reason) |b| { switch (b) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => try writer.writeByte(b), ' ' => try writer.writeByte('+'), else => try writer.writeByte('!'), // some weird character, that we shouldn't have, but that'll we'll replace with a weird (bur url-safe) character } } try writer.writeByte(0); break :blk writer.buffered(); }; var body_buffer: [8192]u8 = undefined; const body = blk: { var writer: std.Io.Writer = .fixed(body_buffer[0..8191]); // reserve 1 space inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| { writer.writeAll(f.name ++ ": ") catch break; @import("log.zig").writeValue(.pretty, @field(args, f.name), &writer) catch {}; writer.writeByte('\n') catch {}; } std.debug.dumpCurrentStackTraceToWriter(begin_addr, &writer) catch {}; const written = writer.buffered(); if (written.len == 0) { break :blk "???"; } // Overwrite the last character with our null terminator // body_buffer always has to be > written body_buffer[written.len] = 0; break :blk body_buffer[0 .. written.len + 1]; }; var argv = [_:null]?[*:0]const u8{ curl_path[0..curl_path_len :0], "-fsSL", "-H", "Content-Type: application/octet-stream", "--data-binary", body[0 .. body.len - 1 :0], url[0 .. url.len - 1 :0], }; const result = std.c.fork(); switch (result) { 0 => { _ = std.c.close(0); _ = std.c.close(1); _ = std.c.close(2); _ = std.c.execve(argv[0].?, &argv, std.c.environ); std.c.exit(0); }, else => return, } } fn curlPath(buf: []u8) ?usize { const path = std.posix.getenv("PATH") orelse return null; var it = std.mem.tokenizeScalar(u8, path, std.fs.path.delimiter); var fba = std.heap.FixedBufferAllocator.init(buf); const allocator = fba.allocator(); const cwd = std.fs.cwd(); while (it.next()) |p| { defer fba.reset(); const full_path = std.fs.path.joinZ(allocator, &.{ p, "curl" }) catch continue; cwd.accessZ(full_path, .{}) catch continue; return full_path.len; } return null; } ================================================ FILE: src/crypto.zig ================================================ //! libcrypto utilities we use throughout browser. const std = @import("std"); const pthread_rwlock_t = std.c.pthread_rwlock_t; pub const struct_env_md_st = opaque {}; pub const EVP_MD = struct_env_md_st; pub const evp_pkey_alg_st = opaque {}; pub const EVP_PKEY_ALG = evp_pkey_alg_st; pub const struct_engine_st = opaque {}; pub const ENGINE = struct_engine_st; pub const CRYPTO_THREADID = c_int; pub const struct_asn1_null_st = opaque {}; pub const ASN1_NULL = struct_asn1_null_st; pub const ASN1_BOOLEAN = c_int; pub const struct_ASN1_ITEM_st = opaque {}; pub const ASN1_ITEM = struct_ASN1_ITEM_st; pub const struct_asn1_object_st = opaque {}; pub const ASN1_OBJECT = struct_asn1_object_st; pub const struct_asn1_pctx_st = opaque {}; pub const ASN1_PCTX = struct_asn1_pctx_st; pub const struct_asn1_string_st = extern struct { length: c_int, type: c_int, data: [*c]u8, flags: c_long, }; pub const ASN1_BIT_STRING = struct_asn1_string_st; pub const ASN1_BMPSTRING = struct_asn1_string_st; pub const ASN1_ENUMERATED = struct_asn1_string_st; pub const ASN1_GENERALIZEDTIME = struct_asn1_string_st; pub const ASN1_GENERALSTRING = struct_asn1_string_st; pub const ASN1_IA5STRING = struct_asn1_string_st; pub const ASN1_INTEGER = struct_asn1_string_st; pub const ASN1_OCTET_STRING = struct_asn1_string_st; pub const ASN1_PRINTABLESTRING = struct_asn1_string_st; pub const ASN1_STRING = struct_asn1_string_st; pub const ASN1_T61STRING = struct_asn1_string_st; pub const ASN1_TIME = struct_asn1_string_st; pub const ASN1_UNIVERSALSTRING = struct_asn1_string_st; pub const ASN1_UTCTIME = struct_asn1_string_st; pub const ASN1_UTF8STRING = struct_asn1_string_st; pub const ASN1_VISIBLESTRING = struct_asn1_string_st; pub const struct_ASN1_VALUE_st = opaque {}; pub const ASN1_VALUE = struct_ASN1_VALUE_st; const union_unnamed_1 = extern union { ptr: [*c]u8, boolean: ASN1_BOOLEAN, asn1_string: [*c]ASN1_STRING, object: ?*ASN1_OBJECT, integer: [*c]ASN1_INTEGER, enumerated: [*c]ASN1_ENUMERATED, bit_string: [*c]ASN1_BIT_STRING, octet_string: [*c]ASN1_OCTET_STRING, printablestring: [*c]ASN1_PRINTABLESTRING, t61string: [*c]ASN1_T61STRING, ia5string: [*c]ASN1_IA5STRING, generalstring: [*c]ASN1_GENERALSTRING, bmpstring: [*c]ASN1_BMPSTRING, universalstring: [*c]ASN1_UNIVERSALSTRING, utctime: [*c]ASN1_UTCTIME, generalizedtime: [*c]ASN1_GENERALIZEDTIME, visiblestring: [*c]ASN1_VISIBLESTRING, utf8string: [*c]ASN1_UTF8STRING, set: [*c]ASN1_STRING, sequence: [*c]ASN1_STRING, asn1_value: ?*ASN1_VALUE, }; pub const struct_asn1_type_st = extern struct { type: c_int, value: union_unnamed_1, }; pub const ASN1_TYPE = struct_asn1_type_st; pub const struct_AUTHORITY_KEYID_st = opaque {}; pub const AUTHORITY_KEYID = struct_AUTHORITY_KEYID_st; pub const struct_BASIC_CONSTRAINTS_st = opaque {}; pub const BASIC_CONSTRAINTS = struct_BASIC_CONSTRAINTS_st; pub const struct_DIST_POINT_st = opaque {}; pub const DIST_POINT = struct_DIST_POINT_st; pub const BN_ULONG = u64; pub const struct_bignum_st = extern struct { d: [*c]BN_ULONG, width: c_int, dmax: c_int, neg: c_int, flags: c_int, }; pub const BIGNUM = struct_bignum_st; pub const struct_DSA_SIG_st = extern struct { r: [*c]BIGNUM, s: [*c]BIGNUM, }; pub const DSA_SIG = struct_DSA_SIG_st; pub const struct_ISSUING_DIST_POINT_st = opaque {}; pub const ISSUING_DIST_POINT = struct_ISSUING_DIST_POINT_st; pub const struct_NAME_CONSTRAINTS_st = opaque {}; pub const NAME_CONSTRAINTS = struct_NAME_CONSTRAINTS_st; pub const struct_X509_pubkey_st = opaque {}; pub const X509_PUBKEY = struct_X509_pubkey_st; pub const struct_Netscape_spkac_st = extern struct { pubkey: ?*X509_PUBKEY, challenge: [*c]ASN1_IA5STRING, }; pub const NETSCAPE_SPKAC = struct_Netscape_spkac_st; pub const struct_X509_algor_st = extern struct { algorithm: ?*ASN1_OBJECT, parameter: [*c]ASN1_TYPE, }; pub const X509_ALGOR = struct_X509_algor_st; pub const struct_Netscape_spki_st = extern struct { spkac: [*c]NETSCAPE_SPKAC, sig_algor: [*c]X509_ALGOR, signature: [*c]ASN1_BIT_STRING, }; pub const NETSCAPE_SPKI = struct_Netscape_spki_st; pub const struct_RIPEMD160state_st = opaque {}; pub const RIPEMD160_CTX = struct_RIPEMD160state_st; pub const struct_X509_VERIFY_PARAM_st = opaque {}; pub const X509_VERIFY_PARAM = struct_X509_VERIFY_PARAM_st; pub const struct_X509_crl_st = opaque {}; pub const X509_CRL = struct_X509_crl_st; pub const struct_X509_extension_st = opaque {}; pub const X509_EXTENSION = struct_X509_extension_st; pub const struct_x509_st = opaque {}; pub const X509 = struct_x509_st; pub const CRYPTO_refcount_t = u32; pub const struct_openssl_method_common_st = extern struct { references: c_int, is_static: u8, }; pub const struct_rsa_meth_st = extern struct { common: struct_openssl_method_common_st, app_data: ?*anyopaque, init: ?*const fn (?*RSA) callconv(.c) c_int, finish: ?*const fn (?*RSA) callconv(.c) c_int, size: ?*const fn (?*const RSA) callconv(.c) usize, sign: ?*const fn (c_int, [*c]const u8, c_uint, [*c]u8, [*c]c_uint, ?*const RSA) callconv(.c) c_int, sign_raw: ?*const fn (?*RSA, [*c]usize, [*c]u8, usize, [*c]const u8, usize, c_int) callconv(.c) c_int, decrypt: ?*const fn (?*RSA, [*c]usize, [*c]u8, usize, [*c]const u8, usize, c_int) callconv(.c) c_int, private_transform: ?*const fn (?*RSA, [*c]u8, [*c]const u8, usize) callconv(.c) c_int, flags: c_int, }; pub const RSA_METHOD = struct_rsa_meth_st; pub const struct_stack_st_void = opaque {}; pub const struct_crypto_ex_data_st = extern struct { sk: ?*struct_stack_st_void, }; pub const CRYPTO_EX_DATA = struct_crypto_ex_data_st; pub const CRYPTO_MUTEX = pthread_rwlock_t; pub const struct_bn_mont_ctx_st = extern struct { RR: BIGNUM, N: BIGNUM, n0: [2]BN_ULONG, }; pub const BN_MONT_CTX = struct_bn_mont_ctx_st; pub const struct_bn_blinding_st = opaque {}; pub const BN_BLINDING = struct_bn_blinding_st; // boringssl/include/openssl/rsa.h:788:12: warning: struct demoted to opaque type - has bitfield pub const struct_rsa_st = opaque {}; pub const RSA = struct_rsa_st; pub const struct_dsa_st = extern struct { version: c_long, p: [*c]BIGNUM, q: [*c]BIGNUM, g: [*c]BIGNUM, pub_key: [*c]BIGNUM, priv_key: [*c]BIGNUM, flags: c_int, method_mont_lock: CRYPTO_MUTEX, method_mont_p: [*c]BN_MONT_CTX, method_mont_q: [*c]BN_MONT_CTX, references: CRYPTO_refcount_t, ex_data: CRYPTO_EX_DATA, }; pub const DSA = struct_dsa_st; pub const struct_dh_st = opaque {}; pub const DH = struct_dh_st; pub const struct_ec_key_st = opaque {}; pub const EC_KEY = struct_ec_key_st; const union_unnamed_2 = extern union { ptr: ?*anyopaque, rsa: ?*RSA, dsa: [*c]DSA, dh: ?*DH, ec: ?*EC_KEY, }; pub const struct_evp_pkey_asn1_method_st = opaque {}; pub const EVP_PKEY_ASN1_METHOD = struct_evp_pkey_asn1_method_st; pub const struct_evp_pkey_st = extern struct { references: CRYPTO_refcount_t, type: c_int, pkey: union_unnamed_2, ameth: ?*const EVP_PKEY_ASN1_METHOD, }; pub const EVP_PKEY = struct_evp_pkey_st; pub const struct_evp_pkey_ctx_st = opaque {}; pub const EVP_PKEY_CTX = struct_evp_pkey_ctx_st; pub extern fn RAND_bytes(buf: [*]u8, len: usize) c_int; pub extern fn EVP_sha1() *const EVP_MD; pub extern fn EVP_sha256() *const EVP_MD; pub extern fn EVP_sha384() *const EVP_MD; pub extern fn EVP_sha512() *const EVP_MD; pub const EVP_MAX_MD_BLOCK_SIZE = 128; pub extern fn EVP_MD_size(md: ?*const EVP_MD) usize; pub extern fn EVP_MD_block_size(md: ?*const EVP_MD) usize; pub extern fn CRYPTO_memcmp(a: ?*const anyopaque, b: ?*const anyopaque, len: usize) c_int; pub extern fn HMAC( evp_md: *const EVP_MD, key: *const anyopaque, key_len: usize, data: [*]const u8, data_len: usize, out: [*]u8, out_len: *c_uint, ) ?[*]u8; pub const X25519_PRIVATE_KEY_LEN = 32; pub const X25519_PUBLIC_VALUE_LEN = 32; pub const X25519_SHARED_KEY_LEN = 32; pub extern fn X25519_keypair(out_public_value: *[32]u8, out_private_key: *[32]u8) void; pub const NID_X25519 = @as(c_int, 948); pub const EVP_PKEY_X25519 = NID_X25519; pub const NID_ED25519 = 949; pub const EVP_PKEY_ED25519 = NID_ED25519; pub extern fn EVP_PKEY_new_raw_private_key(@"type": c_int, unused: ?*ENGINE, in: [*c]const u8, len: usize) [*c]EVP_PKEY; pub extern fn EVP_PKEY_new_raw_public_key(@"type": c_int, unused: ?*ENGINE, in: [*c]const u8, len: usize) [*c]EVP_PKEY; pub extern fn EVP_PKEY_CTX_new(pkey: [*c]EVP_PKEY, e: ?*ENGINE) ?*EVP_PKEY_CTX; pub extern fn EVP_PKEY_CTX_free(ctx: ?*EVP_PKEY_CTX) void; pub extern fn EVP_PKEY_derive_init(ctx: ?*EVP_PKEY_CTX) c_int; pub extern fn EVP_PKEY_derive(ctx: ?*EVP_PKEY_CTX, key: [*c]u8, out_key_len: [*c]usize) c_int; pub extern fn EVP_PKEY_derive_set_peer(ctx: ?*EVP_PKEY_CTX, peer: [*c]EVP_PKEY) c_int; pub extern fn EVP_PKEY_free(pkey: ?*EVP_PKEY) void; pub extern fn EVP_DigestSignInit(ctx: ?*EVP_MD_CTX, pctx: ?*?*EVP_PKEY_CTX, typ: ?*const EVP_MD, e: ?*ENGINE, pkey: ?*EVP_PKEY) c_int; pub extern fn EVP_DigestSign(ctx: ?*EVP_MD_CTX, sig: [*c]u8, sig_len: *usize, data: [*c]const u8, data_len: usize) c_int; pub extern fn EVP_MD_CTX_new() ?*EVP_MD_CTX; pub extern fn EVP_MD_CTX_free(ctx: ?*EVP_MD_CTX) void; pub const struct_evp_md_ctx_st = opaque {}; pub const EVP_MD_CTX = struct_evp_md_ctx_st; ================================================ FILE: src/data/public_suffix_list.zig ================================================ const std = @import("std"); const builtin = @import("builtin"); pub fn lookup(value: []const u8) bool { return public_suffix_list.has(value); } const public_suffix_list = std.StaticStringMap(void).initComptime(entries); const entries: []const struct { []const u8, void } = if (builtin.is_test) &.{ .{ "api.gov.uk", {} }, .{ "gov.uk", {} }, } else &.{ .{ "ac", {} }, .{ "com.ac", {} }, .{ "edu.ac", {} }, .{ "gov.ac", {} }, .{ "mil.ac", {} }, .{ "net.ac", {} }, .{ "org.ac", {} }, .{ "ad", {} }, .{ "ae", {} }, .{ "ac.ae", {} }, .{ "co.ae", {} }, .{ "gov.ae", {} }, .{ "mil.ae", {} }, .{ "net.ae", {} }, .{ "org.ae", {} }, .{ "sch.ae", {} }, .{ "aero", {} }, .{ "airline.aero", {} }, .{ "airport.aero", {} }, .{ "accident-investigation.aero", {} }, .{ "accident-prevention.aero", {} }, .{ "aerobatic.aero", {} }, .{ "aeroclub.aero", {} }, .{ "aerodrome.aero", {} }, .{ "agents.aero", {} }, .{ "air-surveillance.aero", {} }, .{ "air-traffic-control.aero", {} }, .{ "aircraft.aero", {} }, .{ "airtraffic.aero", {} }, .{ "ambulance.aero", {} }, .{ "association.aero", {} }, .{ "author.aero", {} }, .{ "ballooning.aero", {} }, .{ "broker.aero", {} }, .{ "caa.aero", {} }, .{ "cargo.aero", {} }, .{ "catering.aero", {} }, .{ "certification.aero", {} }, .{ "championship.aero", {} }, .{ "charter.aero", {} }, .{ "civilaviation.aero", {} }, .{ "club.aero", {} }, .{ "conference.aero", {} }, .{ "consultant.aero", {} }, .{ "consulting.aero", {} }, .{ "control.aero", {} }, .{ "council.aero", {} }, .{ "crew.aero", {} }, .{ "design.aero", {} }, .{ "dgca.aero", {} }, .{ "educator.aero", {} }, .{ "emergency.aero", {} }, .{ "engine.aero", {} }, .{ "engineer.aero", {} }, .{ "entertainment.aero", {} }, .{ "equipment.aero", {} }, .{ "exchange.aero", {} }, .{ "express.aero", {} }, .{ "federation.aero", {} }, .{ "flight.aero", {} }, .{ "freight.aero", {} }, .{ "fuel.aero", {} }, .{ "gliding.aero", {} }, .{ "government.aero", {} }, .{ "groundhandling.aero", {} }, .{ "group.aero", {} }, .{ "hanggliding.aero", {} }, .{ "homebuilt.aero", {} }, .{ "insurance.aero", {} }, .{ "journal.aero", {} }, .{ "journalist.aero", {} }, .{ "leasing.aero", {} }, .{ "logistics.aero", {} }, .{ "magazine.aero", {} }, .{ "maintenance.aero", {} }, .{ "marketplace.aero", {} }, .{ "media.aero", {} }, .{ "microlight.aero", {} }, .{ "modelling.aero", {} }, .{ "navigation.aero", {} }, .{ "parachuting.aero", {} }, .{ "paragliding.aero", {} }, .{ "passenger-association.aero", {} }, .{ "pilot.aero", {} }, .{ "press.aero", {} }, .{ "production.aero", {} }, .{ "recreation.aero", {} }, .{ "repbody.aero", {} }, .{ "res.aero", {} }, .{ "research.aero", {} }, .{ "rotorcraft.aero", {} }, .{ "safety.aero", {} }, .{ "scientist.aero", {} }, .{ "services.aero", {} }, .{ "show.aero", {} }, .{ "skydiving.aero", {} }, .{ "software.aero", {} }, .{ "student.aero", {} }, .{ "taxi.aero", {} }, .{ "trader.aero", {} }, .{ "trading.aero", {} }, .{ "trainer.aero", {} }, .{ "union.aero", {} }, .{ "workinggroup.aero", {} }, .{ "works.aero", {} }, .{ "af", {} }, .{ "com.af", {} }, .{ "edu.af", {} }, .{ "gov.af", {} }, .{ "net.af", {} }, .{ "org.af", {} }, .{ "ag", {} }, .{ "co.ag", {} }, .{ "com.ag", {} }, .{ "net.ag", {} }, .{ "nom.ag", {} }, .{ "org.ag", {} }, .{ "ai", {} }, .{ "com.ai", {} }, .{ "net.ai", {} }, .{ "off.ai", {} }, .{ "org.ai", {} }, .{ "al", {} }, .{ "com.al", {} }, .{ "edu.al", {} }, .{ "gov.al", {} }, .{ "mil.al", {} }, .{ "net.al", {} }, .{ "org.al", {} }, .{ "am", {} }, .{ "co.am", {} }, .{ "com.am", {} }, .{ "commune.am", {} }, .{ "net.am", {} }, .{ "org.am", {} }, .{ "ao", {} }, .{ "co.ao", {} }, .{ "ed.ao", {} }, .{ "edu.ao", {} }, .{ "gov.ao", {} }, .{ "gv.ao", {} }, .{ "it.ao", {} }, .{ "og.ao", {} }, .{ "org.ao", {} }, .{ "pb.ao", {} }, .{ "aq", {} }, .{ "ar", {} }, .{ "bet.ar", {} }, .{ "com.ar", {} }, .{ "coop.ar", {} }, .{ "edu.ar", {} }, .{ "gob.ar", {} }, .{ "gov.ar", {} }, .{ "int.ar", {} }, .{ "mil.ar", {} }, .{ "musica.ar", {} }, .{ "mutual.ar", {} }, .{ "net.ar", {} }, .{ "org.ar", {} }, .{ "seg.ar", {} }, .{ "senasa.ar", {} }, .{ "tur.ar", {} }, .{ "arpa", {} }, .{ "e164.arpa", {} }, .{ "home.arpa", {} }, .{ "in-addr.arpa", {} }, .{ "ip6.arpa", {} }, .{ "iris.arpa", {} }, .{ "uri.arpa", {} }, .{ "urn.arpa", {} }, .{ "as", {} }, .{ "gov.as", {} }, .{ "asia", {} }, .{ "at", {} }, .{ "ac.at", {} }, .{ "sth.ac.at", {} }, .{ "co.at", {} }, .{ "gv.at", {} }, .{ "or.at", {} }, .{ "au", {} }, .{ "asn.au", {} }, .{ "com.au", {} }, .{ "edu.au", {} }, .{ "gov.au", {} }, .{ "id.au", {} }, .{ "net.au", {} }, .{ "org.au", {} }, .{ "conf.au", {} }, .{ "oz.au", {} }, .{ "act.au", {} }, .{ "nsw.au", {} }, .{ "nt.au", {} }, .{ "qld.au", {} }, .{ "sa.au", {} }, .{ "tas.au", {} }, .{ "vic.au", {} }, .{ "wa.au", {} }, .{ "act.edu.au", {} }, .{ "catholic.edu.au", {} }, .{ "nsw.edu.au", {} }, .{ "nt.edu.au", {} }, .{ "qld.edu.au", {} }, .{ "sa.edu.au", {} }, .{ "tas.edu.au", {} }, .{ "vic.edu.au", {} }, .{ "wa.edu.au", {} }, .{ "qld.gov.au", {} }, .{ "sa.gov.au", {} }, .{ "tas.gov.au", {} }, .{ "vic.gov.au", {} }, .{ "wa.gov.au", {} }, .{ "aw", {} }, .{ "com.aw", {} }, .{ "ax", {} }, .{ "az", {} }, .{ "biz.az", {} }, .{ "co.az", {} }, .{ "com.az", {} }, .{ "edu.az", {} }, .{ "gov.az", {} }, .{ "info.az", {} }, .{ "int.az", {} }, .{ "mil.az", {} }, .{ "name.az", {} }, .{ "net.az", {} }, .{ "org.az", {} }, .{ "pp.az", {} }, .{ "pro.az", {} }, .{ "ba", {} }, .{ "com.ba", {} }, .{ "edu.ba", {} }, .{ "gov.ba", {} }, .{ "mil.ba", {} }, .{ "net.ba", {} }, .{ "org.ba", {} }, .{ "bb", {} }, .{ "biz.bb", {} }, .{ "co.bb", {} }, .{ "com.bb", {} }, .{ "edu.bb", {} }, .{ "gov.bb", {} }, .{ "info.bb", {} }, .{ "net.bb", {} }, .{ "org.bb", {} }, .{ "store.bb", {} }, .{ "tv.bb", {} }, .{ "bd", {} }, .{ "ac.bd", {} }, .{ "ai.bd", {} }, .{ "co.bd", {} }, .{ "com.bd", {} }, .{ "edu.bd", {} }, .{ "gov.bd", {} }, .{ "id.bd", {} }, .{ "info.bd", {} }, .{ "it.bd", {} }, .{ "mil.bd", {} }, .{ "net.bd", {} }, .{ "org.bd", {} }, .{ "sch.bd", {} }, .{ "tv.bd", {} }, .{ "be", {} }, .{ "ac.be", {} }, .{ "bf", {} }, .{ "gov.bf", {} }, .{ "bg", {} }, .{ "0.bg", {} }, .{ "1.bg", {} }, .{ "2.bg", {} }, .{ "3.bg", {} }, .{ "4.bg", {} }, .{ "5.bg", {} }, .{ "6.bg", {} }, .{ "7.bg", {} }, .{ "8.bg", {} }, .{ "9.bg", {} }, .{ "a.bg", {} }, .{ "b.bg", {} }, .{ "c.bg", {} }, .{ "d.bg", {} }, .{ "e.bg", {} }, .{ "f.bg", {} }, .{ "g.bg", {} }, .{ "h.bg", {} }, .{ "i.bg", {} }, .{ "j.bg", {} }, .{ "k.bg", {} }, .{ "l.bg", {} }, .{ "m.bg", {} }, .{ "n.bg", {} }, .{ "o.bg", {} }, .{ "p.bg", {} }, .{ "q.bg", {} }, .{ "r.bg", {} }, .{ "s.bg", {} }, .{ "t.bg", {} }, .{ "u.bg", {} }, .{ "v.bg", {} }, .{ "w.bg", {} }, .{ "x.bg", {} }, .{ "y.bg", {} }, .{ "z.bg", {} }, .{ "bh", {} }, .{ "com.bh", {} }, .{ "edu.bh", {} }, .{ "gov.bh", {} }, .{ "net.bh", {} }, .{ "org.bh", {} }, .{ "bi", {} }, .{ "co.bi", {} }, .{ "com.bi", {} }, .{ "edu.bi", {} }, .{ "or.bi", {} }, .{ "org.bi", {} }, .{ "biz", {} }, .{ "bj", {} }, .{ "africa.bj", {} }, .{ "agro.bj", {} }, .{ "architectes.bj", {} }, .{ "assur.bj", {} }, .{ "avocats.bj", {} }, .{ "co.bj", {} }, .{ "com.bj", {} }, .{ "eco.bj", {} }, .{ "econo.bj", {} }, .{ "edu.bj", {} }, .{ "info.bj", {} }, .{ "loisirs.bj", {} }, .{ "money.bj", {} }, .{ "net.bj", {} }, .{ "org.bj", {} }, .{ "ote.bj", {} }, .{ "restaurant.bj", {} }, .{ "resto.bj", {} }, .{ "tourism.bj", {} }, .{ "univ.bj", {} }, .{ "bm", {} }, .{ "com.bm", {} }, .{ "edu.bm", {} }, .{ "gov.bm", {} }, .{ "net.bm", {} }, .{ "org.bm", {} }, .{ "bn", {} }, .{ "com.bn", {} }, .{ "edu.bn", {} }, .{ "gov.bn", {} }, .{ "net.bn", {} }, .{ "org.bn", {} }, .{ "bo", {} }, .{ "com.bo", {} }, .{ "edu.bo", {} }, .{ "gob.bo", {} }, .{ "int.bo", {} }, .{ "mil.bo", {} }, .{ "net.bo", {} }, .{ "org.bo", {} }, .{ "tv.bo", {} }, .{ "web.bo", {} }, .{ "academia.bo", {} }, .{ "agro.bo", {} }, .{ "arte.bo", {} }, .{ "blog.bo", {} }, .{ "bolivia.bo", {} }, .{ "ciencia.bo", {} }, .{ "cooperativa.bo", {} }, .{ "democracia.bo", {} }, .{ "deporte.bo", {} }, .{ "ecologia.bo", {} }, .{ "economia.bo", {} }, .{ "empresa.bo", {} }, .{ "indigena.bo", {} }, .{ "industria.bo", {} }, .{ "info.bo", {} }, .{ "medicina.bo", {} }, .{ "movimiento.bo", {} }, .{ "musica.bo", {} }, .{ "natural.bo", {} }, .{ "nombre.bo", {} }, .{ "noticias.bo", {} }, .{ "patria.bo", {} }, .{ "plurinacional.bo", {} }, .{ "politica.bo", {} }, .{ "profesional.bo", {} }, .{ "pueblo.bo", {} }, .{ "revista.bo", {} }, .{ "salud.bo", {} }, .{ "tecnologia.bo", {} }, .{ "tksat.bo", {} }, .{ "transporte.bo", {} }, .{ "wiki.bo", {} }, .{ "br", {} }, .{ "9guacu.br", {} }, .{ "abc.br", {} }, .{ "adm.br", {} }, .{ "adv.br", {} }, .{ "agr.br", {} }, .{ "aju.br", {} }, .{ "am.br", {} }, .{ "anani.br", {} }, .{ "aparecida.br", {} }, .{ "api.br", {} }, .{ "app.br", {} }, .{ "arq.br", {} }, .{ "art.br", {} }, .{ "ato.br", {} }, .{ "b.br", {} }, .{ "barueri.br", {} }, .{ "belem.br", {} }, .{ "bet.br", {} }, .{ "bhz.br", {} }, .{ "bib.br", {} }, .{ "bio.br", {} }, .{ "blog.br", {} }, .{ "bmd.br", {} }, .{ "boavista.br", {} }, .{ "bsb.br", {} }, .{ "campinagrande.br", {} }, .{ "campinas.br", {} }, .{ "caxias.br", {} }, .{ "cim.br", {} }, .{ "cng.br", {} }, .{ "cnt.br", {} }, .{ "com.br", {} }, .{ "contagem.br", {} }, .{ "coop.br", {} }, .{ "coz.br", {} }, .{ "cri.br", {} }, .{ "cuiaba.br", {} }, .{ "curitiba.br", {} }, .{ "def.br", {} }, .{ "des.br", {} }, .{ "det.br", {} }, .{ "dev.br", {} }, .{ "ecn.br", {} }, .{ "eco.br", {} }, .{ "edu.br", {} }, .{ "emp.br", {} }, .{ "enf.br", {} }, .{ "eng.br", {} }, .{ "esp.br", {} }, .{ "etc.br", {} }, .{ "eti.br", {} }, .{ "far.br", {} }, .{ "feira.br", {} }, .{ "flog.br", {} }, .{ "floripa.br", {} }, .{ "fm.br", {} }, .{ "fnd.br", {} }, .{ "fortal.br", {} }, .{ "fot.br", {} }, .{ "foz.br", {} }, .{ "fst.br", {} }, .{ "g12.br", {} }, .{ "geo.br", {} }, .{ "ggf.br", {} }, .{ "goiania.br", {} }, .{ "gov.br", {} }, .{ "ac.gov.br", {} }, .{ "al.gov.br", {} }, .{ "am.gov.br", {} }, .{ "ap.gov.br", {} }, .{ "ba.gov.br", {} }, .{ "ce.gov.br", {} }, .{ "df.gov.br", {} }, .{ "es.gov.br", {} }, .{ "go.gov.br", {} }, .{ "ma.gov.br", {} }, .{ "mg.gov.br", {} }, .{ "ms.gov.br", {} }, .{ "mt.gov.br", {} }, .{ "pa.gov.br", {} }, .{ "pb.gov.br", {} }, .{ "pe.gov.br", {} }, .{ "pi.gov.br", {} }, .{ "pr.gov.br", {} }, .{ "rj.gov.br", {} }, .{ "rn.gov.br", {} }, .{ "ro.gov.br", {} }, .{ "rr.gov.br", {} }, .{ "rs.gov.br", {} }, .{ "sc.gov.br", {} }, .{ "se.gov.br", {} }, .{ "sp.gov.br", {} }, .{ "to.gov.br", {} }, .{ "gru.br", {} }, .{ "ia.br", {} }, .{ "imb.br", {} }, .{ "ind.br", {} }, .{ "inf.br", {} }, .{ "jab.br", {} }, .{ "jampa.br", {} }, .{ "jdf.br", {} }, .{ "joinville.br", {} }, .{ "jor.br", {} }, .{ "jus.br", {} }, .{ "leg.br", {} }, .{ "leilao.br", {} }, .{ "lel.br", {} }, .{ "log.br", {} }, .{ "londrina.br", {} }, .{ "macapa.br", {} }, .{ "maceio.br", {} }, .{ "manaus.br", {} }, .{ "maringa.br", {} }, .{ "mat.br", {} }, .{ "med.br", {} }, .{ "mil.br", {} }, .{ "morena.br", {} }, .{ "mp.br", {} }, .{ "mus.br", {} }, .{ "natal.br", {} }, .{ "net.br", {} }, .{ "niteroi.br", {} }, .{ "*.nom.br", {} }, .{ "not.br", {} }, .{ "ntr.br", {} }, .{ "odo.br", {} }, .{ "ong.br", {} }, .{ "org.br", {} }, .{ "osasco.br", {} }, .{ "palmas.br", {} }, .{ "poa.br", {} }, .{ "ppg.br", {} }, .{ "pro.br", {} }, .{ "psc.br", {} }, .{ "psi.br", {} }, .{ "pvh.br", {} }, .{ "qsl.br", {} }, .{ "radio.br", {} }, .{ "rec.br", {} }, .{ "recife.br", {} }, .{ "rep.br", {} }, .{ "ribeirao.br", {} }, .{ "rio.br", {} }, .{ "riobranco.br", {} }, .{ "riopreto.br", {} }, .{ "salvador.br", {} }, .{ "sampa.br", {} }, .{ "santamaria.br", {} }, .{ "santoandre.br", {} }, .{ "saobernardo.br", {} }, .{ "saogonca.br", {} }, .{ "seg.br", {} }, .{ "sjc.br", {} }, .{ "slg.br", {} }, .{ "slz.br", {} }, .{ "social.br", {} }, .{ "sorocaba.br", {} }, .{ "srv.br", {} }, .{ "taxi.br", {} }, .{ "tc.br", {} }, .{ "tec.br", {} }, .{ "teo.br", {} }, .{ "the.br", {} }, .{ "tmp.br", {} }, .{ "trd.br", {} }, .{ "tur.br", {} }, .{ "tv.br", {} }, .{ "udi.br", {} }, .{ "vet.br", {} }, .{ "vix.br", {} }, .{ "vlog.br", {} }, .{ "wiki.br", {} }, .{ "xyz.br", {} }, .{ "zlg.br", {} }, .{ "bs", {} }, .{ "com.bs", {} }, .{ "edu.bs", {} }, .{ "gov.bs", {} }, .{ "net.bs", {} }, .{ "org.bs", {} }, .{ "bt", {} }, .{ "com.bt", {} }, .{ "edu.bt", {} }, .{ "gov.bt", {} }, .{ "net.bt", {} }, .{ "org.bt", {} }, .{ "bv", {} }, .{ "bw", {} }, .{ "ac.bw", {} }, .{ "co.bw", {} }, .{ "gov.bw", {} }, .{ "net.bw", {} }, .{ "org.bw", {} }, .{ "by", {} }, .{ "gov.by", {} }, .{ "mil.by", {} }, .{ "com.by", {} }, .{ "of.by", {} }, .{ "bz", {} }, .{ "co.bz", {} }, .{ "com.bz", {} }, .{ "edu.bz", {} }, .{ "gov.bz", {} }, .{ "net.bz", {} }, .{ "org.bz", {} }, .{ "ca", {} }, .{ "ab.ca", {} }, .{ "bc.ca", {} }, .{ "mb.ca", {} }, .{ "nb.ca", {} }, .{ "nf.ca", {} }, .{ "nl.ca", {} }, .{ "ns.ca", {} }, .{ "nt.ca", {} }, .{ "nu.ca", {} }, .{ "on.ca", {} }, .{ "pe.ca", {} }, .{ "qc.ca", {} }, .{ "sk.ca", {} }, .{ "yk.ca", {} }, .{ "gc.ca", {} }, .{ "cat", {} }, .{ "cc", {} }, .{ "cd", {} }, .{ "gov.cd", {} }, .{ "cf", {} }, .{ "cg", {} }, .{ "ch", {} }, .{ "ci", {} }, .{ "ac.ci", {} }, .{ "aéroport.ci", {} }, .{ "asso.ci", {} }, .{ "co.ci", {} }, .{ "com.ci", {} }, .{ "ed.ci", {} }, .{ "edu.ci", {} }, .{ "go.ci", {} }, .{ "gouv.ci", {} }, .{ "int.ci", {} }, .{ "net.ci", {} }, .{ "or.ci", {} }, .{ "org.ci", {} }, .{ "*.ck", {} }, .{ "!www.ck", {} }, .{ "cl", {} }, .{ "co.cl", {} }, .{ "gob.cl", {} }, .{ "gov.cl", {} }, .{ "mil.cl", {} }, .{ "cm", {} }, .{ "co.cm", {} }, .{ "com.cm", {} }, .{ "gov.cm", {} }, .{ "net.cm", {} }, .{ "cn", {} }, .{ "ac.cn", {} }, .{ "com.cn", {} }, .{ "edu.cn", {} }, .{ "gov.cn", {} }, .{ "mil.cn", {} }, .{ "net.cn", {} }, .{ "org.cn", {} }, .{ "公司.cn", {} }, .{ "網絡.cn", {} }, .{ "网络.cn", {} }, .{ "ah.cn", {} }, .{ "bj.cn", {} }, .{ "cq.cn", {} }, .{ "fj.cn", {} }, .{ "gd.cn", {} }, .{ "gs.cn", {} }, .{ "gx.cn", {} }, .{ "gz.cn", {} }, .{ "ha.cn", {} }, .{ "hb.cn", {} }, .{ "he.cn", {} }, .{ "hi.cn", {} }, .{ "hk.cn", {} }, .{ "hl.cn", {} }, .{ "hn.cn", {} }, .{ "jl.cn", {} }, .{ "js.cn", {} }, .{ "jx.cn", {} }, .{ "ln.cn", {} }, .{ "mo.cn", {} }, .{ "nm.cn", {} }, .{ "nx.cn", {} }, .{ "qh.cn", {} }, .{ "sc.cn", {} }, .{ "sd.cn", {} }, .{ "sh.cn", {} }, .{ "sn.cn", {} }, .{ "sx.cn", {} }, .{ "tj.cn", {} }, .{ "tw.cn", {} }, .{ "xj.cn", {} }, .{ "xz.cn", {} }, .{ "yn.cn", {} }, .{ "zj.cn", {} }, .{ "co", {} }, .{ "com.co", {} }, .{ "edu.co", {} }, .{ "gov.co", {} }, .{ "mil.co", {} }, .{ "net.co", {} }, .{ "nom.co", {} }, .{ "org.co", {} }, .{ "com", {} }, .{ "coop", {} }, .{ "cr", {} }, .{ "ac.cr", {} }, .{ "co.cr", {} }, .{ "ed.cr", {} }, .{ "fi.cr", {} }, .{ "go.cr", {} }, .{ "or.cr", {} }, .{ "sa.cr", {} }, .{ "cu", {} }, .{ "com.cu", {} }, .{ "edu.cu", {} }, .{ "gob.cu", {} }, .{ "inf.cu", {} }, .{ "nat.cu", {} }, .{ "net.cu", {} }, .{ "org.cu", {} }, .{ "cv", {} }, .{ "com.cv", {} }, .{ "edu.cv", {} }, .{ "id.cv", {} }, .{ "int.cv", {} }, .{ "net.cv", {} }, .{ "nome.cv", {} }, .{ "org.cv", {} }, .{ "publ.cv", {} }, .{ "cw", {} }, .{ "com.cw", {} }, .{ "edu.cw", {} }, .{ "net.cw", {} }, .{ "org.cw", {} }, .{ "cx", {} }, .{ "gov.cx", {} }, .{ "cy", {} }, .{ "ac.cy", {} }, .{ "biz.cy", {} }, .{ "com.cy", {} }, .{ "ekloges.cy", {} }, .{ "gov.cy", {} }, .{ "ltd.cy", {} }, .{ "mil.cy", {} }, .{ "net.cy", {} }, .{ "org.cy", {} }, .{ "press.cy", {} }, .{ "pro.cy", {} }, .{ "tm.cy", {} }, .{ "cz", {} }, .{ "gov.cz", {} }, .{ "de", {} }, .{ "dj", {} }, .{ "dk", {} }, .{ "dm", {} }, .{ "co.dm", {} }, .{ "com.dm", {} }, .{ "edu.dm", {} }, .{ "gov.dm", {} }, .{ "net.dm", {} }, .{ "org.dm", {} }, .{ "do", {} }, .{ "art.do", {} }, .{ "com.do", {} }, .{ "edu.do", {} }, .{ "gob.do", {} }, .{ "gov.do", {} }, .{ "mil.do", {} }, .{ "net.do", {} }, .{ "org.do", {} }, .{ "sld.do", {} }, .{ "web.do", {} }, .{ "dz", {} }, .{ "art.dz", {} }, .{ "asso.dz", {} }, .{ "com.dz", {} }, .{ "edu.dz", {} }, .{ "gov.dz", {} }, .{ "net.dz", {} }, .{ "org.dz", {} }, .{ "pol.dz", {} }, .{ "soc.dz", {} }, .{ "tm.dz", {} }, .{ "ec", {} }, .{ "abg.ec", {} }, .{ "adm.ec", {} }, .{ "agron.ec", {} }, .{ "arqt.ec", {} }, .{ "art.ec", {} }, .{ "bar.ec", {} }, .{ "chef.ec", {} }, .{ "com.ec", {} }, .{ "cont.ec", {} }, .{ "cpa.ec", {} }, .{ "cue.ec", {} }, .{ "dent.ec", {} }, .{ "dgn.ec", {} }, .{ "disco.ec", {} }, .{ "doc.ec", {} }, .{ "edu.ec", {} }, .{ "eng.ec", {} }, .{ "esm.ec", {} }, .{ "fin.ec", {} }, .{ "fot.ec", {} }, .{ "gal.ec", {} }, .{ "gob.ec", {} }, .{ "gov.ec", {} }, .{ "gye.ec", {} }, .{ "ibr.ec", {} }, .{ "info.ec", {} }, .{ "k12.ec", {} }, .{ "lat.ec", {} }, .{ "loj.ec", {} }, .{ "med.ec", {} }, .{ "mil.ec", {} }, .{ "mktg.ec", {} }, .{ "mon.ec", {} }, .{ "net.ec", {} }, .{ "ntr.ec", {} }, .{ "odont.ec", {} }, .{ "org.ec", {} }, .{ "pro.ec", {} }, .{ "prof.ec", {} }, .{ "psic.ec", {} }, .{ "psiq.ec", {} }, .{ "pub.ec", {} }, .{ "rio.ec", {} }, .{ "rrpp.ec", {} }, .{ "sal.ec", {} }, .{ "tech.ec", {} }, .{ "tul.ec", {} }, .{ "tur.ec", {} }, .{ "uio.ec", {} }, .{ "vet.ec", {} }, .{ "xxx.ec", {} }, .{ "edu", {} }, .{ "ee", {} }, .{ "aip.ee", {} }, .{ "com.ee", {} }, .{ "edu.ee", {} }, .{ "fie.ee", {} }, .{ "gov.ee", {} }, .{ "lib.ee", {} }, .{ "med.ee", {} }, .{ "org.ee", {} }, .{ "pri.ee", {} }, .{ "riik.ee", {} }, .{ "eg", {} }, .{ "ac.eg", {} }, .{ "com.eg", {} }, .{ "edu.eg", {} }, .{ "eun.eg", {} }, .{ "gov.eg", {} }, .{ "info.eg", {} }, .{ "me.eg", {} }, .{ "mil.eg", {} }, .{ "name.eg", {} }, .{ "net.eg", {} }, .{ "org.eg", {} }, .{ "sci.eg", {} }, .{ "sport.eg", {} }, .{ "tv.eg", {} }, .{ "*.er", {} }, .{ "es", {} }, .{ "com.es", {} }, .{ "edu.es", {} }, .{ "gob.es", {} }, .{ "nom.es", {} }, .{ "org.es", {} }, .{ "et", {} }, .{ "biz.et", {} }, .{ "com.et", {} }, .{ "edu.et", {} }, .{ "gov.et", {} }, .{ "info.et", {} }, .{ "name.et", {} }, .{ "net.et", {} }, .{ "org.et", {} }, .{ "eu", {} }, .{ "fi", {} }, .{ "aland.fi", {} }, .{ "fj", {} }, .{ "ac.fj", {} }, .{ "biz.fj", {} }, .{ "com.fj", {} }, .{ "edu.fj", {} }, .{ "gov.fj", {} }, .{ "id.fj", {} }, .{ "info.fj", {} }, .{ "mil.fj", {} }, .{ "name.fj", {} }, .{ "net.fj", {} }, .{ "org.fj", {} }, .{ "pro.fj", {} }, .{ "*.fk", {} }, .{ "fm", {} }, .{ "com.fm", {} }, .{ "edu.fm", {} }, .{ "net.fm", {} }, .{ "org.fm", {} }, .{ "fo", {} }, .{ "fr", {} }, .{ "asso.fr", {} }, .{ "com.fr", {} }, .{ "gouv.fr", {} }, .{ "nom.fr", {} }, .{ "prd.fr", {} }, .{ "tm.fr", {} }, .{ "avoues.fr", {} }, .{ "cci.fr", {} }, .{ "greta.fr", {} }, .{ "huissier-justice.fr", {} }, .{ "ga", {} }, .{ "gb", {} }, .{ "gd", {} }, .{ "edu.gd", {} }, .{ "gov.gd", {} }, .{ "ge", {} }, .{ "com.ge", {} }, .{ "edu.ge", {} }, .{ "gov.ge", {} }, .{ "net.ge", {} }, .{ "org.ge", {} }, .{ "pvt.ge", {} }, .{ "school.ge", {} }, .{ "gf", {} }, .{ "gg", {} }, .{ "co.gg", {} }, .{ "net.gg", {} }, .{ "org.gg", {} }, .{ "gh", {} }, .{ "biz.gh", {} }, .{ "com.gh", {} }, .{ "edu.gh", {} }, .{ "gov.gh", {} }, .{ "mil.gh", {} }, .{ "net.gh", {} }, .{ "org.gh", {} }, .{ "gi", {} }, .{ "com.gi", {} }, .{ "edu.gi", {} }, .{ "gov.gi", {} }, .{ "ltd.gi", {} }, .{ "mod.gi", {} }, .{ "org.gi", {} }, .{ "gl", {} }, .{ "co.gl", {} }, .{ "com.gl", {} }, .{ "edu.gl", {} }, .{ "net.gl", {} }, .{ "org.gl", {} }, .{ "gm", {} }, .{ "gn", {} }, .{ "ac.gn", {} }, .{ "com.gn", {} }, .{ "edu.gn", {} }, .{ "gov.gn", {} }, .{ "net.gn", {} }, .{ "org.gn", {} }, .{ "gov", {} }, .{ "gp", {} }, .{ "asso.gp", {} }, .{ "com.gp", {} }, .{ "edu.gp", {} }, .{ "mobi.gp", {} }, .{ "net.gp", {} }, .{ "org.gp", {} }, .{ "gq", {} }, .{ "gr", {} }, .{ "com.gr", {} }, .{ "edu.gr", {} }, .{ "gov.gr", {} }, .{ "net.gr", {} }, .{ "org.gr", {} }, .{ "gs", {} }, .{ "gt", {} }, .{ "com.gt", {} }, .{ "edu.gt", {} }, .{ "gob.gt", {} }, .{ "ind.gt", {} }, .{ "mil.gt", {} }, .{ "net.gt", {} }, .{ "org.gt", {} }, .{ "gu", {} }, .{ "com.gu", {} }, .{ "edu.gu", {} }, .{ "gov.gu", {} }, .{ "guam.gu", {} }, .{ "info.gu", {} }, .{ "net.gu", {} }, .{ "org.gu", {} }, .{ "web.gu", {} }, .{ "gw", {} }, .{ "gy", {} }, .{ "co.gy", {} }, .{ "com.gy", {} }, .{ "edu.gy", {} }, .{ "gov.gy", {} }, .{ "net.gy", {} }, .{ "org.gy", {} }, .{ "hk", {} }, .{ "com.hk", {} }, .{ "edu.hk", {} }, .{ "gov.hk", {} }, .{ "idv.hk", {} }, .{ "net.hk", {} }, .{ "org.hk", {} }, .{ "个人.hk", {} }, .{ "個人.hk", {} }, .{ "公司.hk", {} }, .{ "政府.hk", {} }, .{ "敎育.hk", {} }, .{ "教育.hk", {} }, .{ "箇人.hk", {} }, .{ "組織.hk", {} }, .{ "組织.hk", {} }, .{ "網絡.hk", {} }, .{ "網络.hk", {} }, .{ "组織.hk", {} }, .{ "组织.hk", {} }, .{ "网絡.hk", {} }, .{ "网络.hk", {} }, .{ "hm", {} }, .{ "hn", {} }, .{ "com.hn", {} }, .{ "edu.hn", {} }, .{ "gob.hn", {} }, .{ "mil.hn", {} }, .{ "net.hn", {} }, .{ "org.hn", {} }, .{ "hr", {} }, .{ "com.hr", {} }, .{ "from.hr", {} }, .{ "iz.hr", {} }, .{ "name.hr", {} }, .{ "ht", {} }, .{ "adult.ht", {} }, .{ "art.ht", {} }, .{ "asso.ht", {} }, .{ "com.ht", {} }, .{ "coop.ht", {} }, .{ "edu.ht", {} }, .{ "firm.ht", {} }, .{ "gouv.ht", {} }, .{ "info.ht", {} }, .{ "med.ht", {} }, .{ "net.ht", {} }, .{ "org.ht", {} }, .{ "perso.ht", {} }, .{ "pol.ht", {} }, .{ "pro.ht", {} }, .{ "rel.ht", {} }, .{ "shop.ht", {} }, .{ "hu", {} }, .{ "2000.hu", {} }, .{ "agrar.hu", {} }, .{ "bolt.hu", {} }, .{ "casino.hu", {} }, .{ "city.hu", {} }, .{ "co.hu", {} }, .{ "erotica.hu", {} }, .{ "erotika.hu", {} }, .{ "film.hu", {} }, .{ "forum.hu", {} }, .{ "games.hu", {} }, .{ "hotel.hu", {} }, .{ "info.hu", {} }, .{ "ingatlan.hu", {} }, .{ "jogasz.hu", {} }, .{ "konyvelo.hu", {} }, .{ "lakas.hu", {} }, .{ "media.hu", {} }, .{ "news.hu", {} }, .{ "org.hu", {} }, .{ "priv.hu", {} }, .{ "reklam.hu", {} }, .{ "sex.hu", {} }, .{ "shop.hu", {} }, .{ "sport.hu", {} }, .{ "suli.hu", {} }, .{ "szex.hu", {} }, .{ "tm.hu", {} }, .{ "tozsde.hu", {} }, .{ "utazas.hu", {} }, .{ "video.hu", {} }, .{ "id", {} }, .{ "ac.id", {} }, .{ "biz.id", {} }, .{ "co.id", {} }, .{ "desa.id", {} }, .{ "go.id", {} }, .{ "kop.id", {} }, .{ "mil.id", {} }, .{ "my.id", {} }, .{ "net.id", {} }, .{ "or.id", {} }, .{ "ponpes.id", {} }, .{ "sch.id", {} }, .{ "web.id", {} }, .{ "ᬩᬮᬶ.id", {} }, .{ "ie", {} }, .{ "gov.ie", {} }, .{ "il", {} }, .{ "ac.il", {} }, .{ "co.il", {} }, .{ "gov.il", {} }, .{ "idf.il", {} }, .{ "k12.il", {} }, .{ "muni.il", {} }, .{ "net.il", {} }, .{ "org.il", {} }, .{ "ישראל", {} }, .{ "אקדמיה.ישראל", {} }, .{ "ישוב.ישראל", {} }, .{ "צהל.ישראל", {} }, .{ "ממשל.ישראל", {} }, .{ "im", {} }, .{ "ac.im", {} }, .{ "co.im", {} }, .{ "ltd.co.im", {} }, .{ "plc.co.im", {} }, .{ "com.im", {} }, .{ "net.im", {} }, .{ "org.im", {} }, .{ "tt.im", {} }, .{ "tv.im", {} }, .{ "in", {} }, .{ "5g.in", {} }, .{ "6g.in", {} }, .{ "ac.in", {} }, .{ "ai.in", {} }, .{ "am.in", {} }, .{ "bank.in", {} }, .{ "bihar.in", {} }, .{ "biz.in", {} }, .{ "business.in", {} }, .{ "ca.in", {} }, .{ "cn.in", {} }, .{ "co.in", {} }, .{ "com.in", {} }, .{ "coop.in", {} }, .{ "cs.in", {} }, .{ "delhi.in", {} }, .{ "dr.in", {} }, .{ "edu.in", {} }, .{ "er.in", {} }, .{ "fin.in", {} }, .{ "firm.in", {} }, .{ "gen.in", {} }, .{ "gov.in", {} }, .{ "gujarat.in", {} }, .{ "ind.in", {} }, .{ "info.in", {} }, .{ "int.in", {} }, .{ "internet.in", {} }, .{ "io.in", {} }, .{ "me.in", {} }, .{ "mil.in", {} }, .{ "net.in", {} }, .{ "nic.in", {} }, .{ "org.in", {} }, .{ "pg.in", {} }, .{ "post.in", {} }, .{ "pro.in", {} }, .{ "res.in", {} }, .{ "travel.in", {} }, .{ "tv.in", {} }, .{ "uk.in", {} }, .{ "up.in", {} }, .{ "us.in", {} }, .{ "info", {} }, .{ "int", {} }, .{ "eu.int", {} }, .{ "io", {} }, .{ "co.io", {} }, .{ "com.io", {} }, .{ "edu.io", {} }, .{ "gov.io", {} }, .{ "mil.io", {} }, .{ "net.io", {} }, .{ "nom.io", {} }, .{ "org.io", {} }, .{ "iq", {} }, .{ "com.iq", {} }, .{ "edu.iq", {} }, .{ "gov.iq", {} }, .{ "mil.iq", {} }, .{ "net.iq", {} }, .{ "org.iq", {} }, .{ "ir", {} }, .{ "ac.ir", {} }, .{ "co.ir", {} }, .{ "gov.ir", {} }, .{ "id.ir", {} }, .{ "net.ir", {} }, .{ "org.ir", {} }, .{ "sch.ir", {} }, .{ "ایران.ir", {} }, .{ "ايران.ir", {} }, .{ "is", {} }, .{ "it", {} }, .{ "edu.it", {} }, .{ "gov.it", {} }, .{ "abr.it", {} }, .{ "abruzzo.it", {} }, .{ "aosta-valley.it", {} }, .{ "aostavalley.it", {} }, .{ "bas.it", {} }, .{ "basilicata.it", {} }, .{ "cal.it", {} }, .{ "calabria.it", {} }, .{ "cam.it", {} }, .{ "campania.it", {} }, .{ "emilia-romagna.it", {} }, .{ "emiliaromagna.it", {} }, .{ "emr.it", {} }, .{ "friuli-v-giulia.it", {} }, .{ "friuli-ve-giulia.it", {} }, .{ "friuli-vegiulia.it", {} }, .{ "friuli-venezia-giulia.it", {} }, .{ "friuli-veneziagiulia.it", {} }, .{ "friuli-vgiulia.it", {} }, .{ "friuliv-giulia.it", {} }, .{ "friulive-giulia.it", {} }, .{ "friulivegiulia.it", {} }, .{ "friulivenezia-giulia.it", {} }, .{ "friuliveneziagiulia.it", {} }, .{ "friulivgiulia.it", {} }, .{ "fvg.it", {} }, .{ "laz.it", {} }, .{ "lazio.it", {} }, .{ "lig.it", {} }, .{ "liguria.it", {} }, .{ "lom.it", {} }, .{ "lombardia.it", {} }, .{ "lombardy.it", {} }, .{ "lucania.it", {} }, .{ "mar.it", {} }, .{ "marche.it", {} }, .{ "mol.it", {} }, .{ "molise.it", {} }, .{ "piedmont.it", {} }, .{ "piemonte.it", {} }, .{ "pmn.it", {} }, .{ "pug.it", {} }, .{ "puglia.it", {} }, .{ "sar.it", {} }, .{ "sardegna.it", {} }, .{ "sardinia.it", {} }, .{ "sic.it", {} }, .{ "sicilia.it", {} }, .{ "sicily.it", {} }, .{ "taa.it", {} }, .{ "tos.it", {} }, .{ "toscana.it", {} }, .{ "trentin-sud-tirol.it", {} }, .{ "trentin-süd-tirol.it", {} }, .{ "trentin-sudtirol.it", {} }, .{ "trentin-südtirol.it", {} }, .{ "trentin-sued-tirol.it", {} }, .{ "trentin-suedtirol.it", {} }, .{ "trentino.it", {} }, .{ "trentino-a-adige.it", {} }, .{ "trentino-aadige.it", {} }, .{ "trentino-alto-adige.it", {} }, .{ "trentino-altoadige.it", {} }, .{ "trentino-s-tirol.it", {} }, .{ "trentino-stirol.it", {} }, .{ "trentino-sud-tirol.it", {} }, .{ "trentino-süd-tirol.it", {} }, .{ "trentino-sudtirol.it", {} }, .{ "trentino-südtirol.it", {} }, .{ "trentino-sued-tirol.it", {} }, .{ "trentino-suedtirol.it", {} }, .{ "trentinoa-adige.it", {} }, .{ "trentinoaadige.it", {} }, .{ "trentinoalto-adige.it", {} }, .{ "trentinoaltoadige.it", {} }, .{ "trentinos-tirol.it", {} }, .{ "trentinostirol.it", {} }, .{ "trentinosud-tirol.it", {} }, .{ "trentinosüd-tirol.it", {} }, .{ "trentinosudtirol.it", {} }, .{ "trentinosüdtirol.it", {} }, .{ "trentinosued-tirol.it", {} }, .{ "trentinosuedtirol.it", {} }, .{ "trentinsud-tirol.it", {} }, .{ "trentinsüd-tirol.it", {} }, .{ "trentinsudtirol.it", {} }, .{ "trentinsüdtirol.it", {} }, .{ "trentinsued-tirol.it", {} }, .{ "trentinsuedtirol.it", {} }, .{ "tuscany.it", {} }, .{ "umb.it", {} }, .{ "umbria.it", {} }, .{ "val-d-aosta.it", {} }, .{ "val-daosta.it", {} }, .{ "vald-aosta.it", {} }, .{ "valdaosta.it", {} }, .{ "valle-aosta.it", {} }, .{ "valle-d-aosta.it", {} }, .{ "valle-daosta.it", {} }, .{ "valleaosta.it", {} }, .{ "valled-aosta.it", {} }, .{ "valledaosta.it", {} }, .{ "vallee-aoste.it", {} }, .{ "vallée-aoste.it", {} }, .{ "vallee-d-aoste.it", {} }, .{ "vallée-d-aoste.it", {} }, .{ "valleeaoste.it", {} }, .{ "valléeaoste.it", {} }, .{ "valleedaoste.it", {} }, .{ "valléedaoste.it", {} }, .{ "vao.it", {} }, .{ "vda.it", {} }, .{ "ven.it", {} }, .{ "veneto.it", {} }, .{ "ag.it", {} }, .{ "agrigento.it", {} }, .{ "al.it", {} }, .{ "alessandria.it", {} }, .{ "alto-adige.it", {} }, .{ "altoadige.it", {} }, .{ "an.it", {} }, .{ "ancona.it", {} }, .{ "andria-barletta-trani.it", {} }, .{ "andria-trani-barletta.it", {} }, .{ "andriabarlettatrani.it", {} }, .{ "andriatranibarletta.it", {} }, .{ "ao.it", {} }, .{ "aosta.it", {} }, .{ "aoste.it", {} }, .{ "ap.it", {} }, .{ "aq.it", {} }, .{ "aquila.it", {} }, .{ "ar.it", {} }, .{ "arezzo.it", {} }, .{ "ascoli-piceno.it", {} }, .{ "ascolipiceno.it", {} }, .{ "asti.it", {} }, .{ "at.it", {} }, .{ "av.it", {} }, .{ "avellino.it", {} }, .{ "ba.it", {} }, .{ "balsan.it", {} }, .{ "balsan-sudtirol.it", {} }, .{ "balsan-südtirol.it", {} }, .{ "balsan-suedtirol.it", {} }, .{ "bari.it", {} }, .{ "barletta-trani-andria.it", {} }, .{ "barlettatraniandria.it", {} }, .{ "belluno.it", {} }, .{ "benevento.it", {} }, .{ "bergamo.it", {} }, .{ "bg.it", {} }, .{ "bi.it", {} }, .{ "biella.it", {} }, .{ "bl.it", {} }, .{ "bn.it", {} }, .{ "bo.it", {} }, .{ "bologna.it", {} }, .{ "bolzano.it", {} }, .{ "bolzano-altoadige.it", {} }, .{ "bozen.it", {} }, .{ "bozen-sudtirol.it", {} }, .{ "bozen-südtirol.it", {} }, .{ "bozen-suedtirol.it", {} }, .{ "br.it", {} }, .{ "brescia.it", {} }, .{ "brindisi.it", {} }, .{ "bs.it", {} }, .{ "bt.it", {} }, .{ "bulsan.it", {} }, .{ "bulsan-sudtirol.it", {} }, .{ "bulsan-südtirol.it", {} }, .{ "bulsan-suedtirol.it", {} }, .{ "bz.it", {} }, .{ "ca.it", {} }, .{ "cagliari.it", {} }, .{ "caltanissetta.it", {} }, .{ "campidano-medio.it", {} }, .{ "campidanomedio.it", {} }, .{ "campobasso.it", {} }, .{ "carbonia-iglesias.it", {} }, .{ "carboniaiglesias.it", {} }, .{ "carrara-massa.it", {} }, .{ "carraramassa.it", {} }, .{ "caserta.it", {} }, .{ "catania.it", {} }, .{ "catanzaro.it", {} }, .{ "cb.it", {} }, .{ "ce.it", {} }, .{ "cesena-forli.it", {} }, .{ "cesena-forlì.it", {} }, .{ "cesenaforli.it", {} }, .{ "cesenaforlì.it", {} }, .{ "ch.it", {} }, .{ "chieti.it", {} }, .{ "ci.it", {} }, .{ "cl.it", {} }, .{ "cn.it", {} }, .{ "co.it", {} }, .{ "como.it", {} }, .{ "cosenza.it", {} }, .{ "cr.it", {} }, .{ "cremona.it", {} }, .{ "crotone.it", {} }, .{ "cs.it", {} }, .{ "ct.it", {} }, .{ "cuneo.it", {} }, .{ "cz.it", {} }, .{ "dell-ogliastra.it", {} }, .{ "dellogliastra.it", {} }, .{ "en.it", {} }, .{ "enna.it", {} }, .{ "fc.it", {} }, .{ "fe.it", {} }, .{ "fermo.it", {} }, .{ "ferrara.it", {} }, .{ "fg.it", {} }, .{ "fi.it", {} }, .{ "firenze.it", {} }, .{ "florence.it", {} }, .{ "fm.it", {} }, .{ "foggia.it", {} }, .{ "forli-cesena.it", {} }, .{ "forlì-cesena.it", {} }, .{ "forlicesena.it", {} }, .{ "forlìcesena.it", {} }, .{ "fr.it", {} }, .{ "frosinone.it", {} }, .{ "ge.it", {} }, .{ "genoa.it", {} }, .{ "genova.it", {} }, .{ "go.it", {} }, .{ "gorizia.it", {} }, .{ "gr.it", {} }, .{ "grosseto.it", {} }, .{ "iglesias-carbonia.it", {} }, .{ "iglesiascarbonia.it", {} }, .{ "im.it", {} }, .{ "imperia.it", {} }, .{ "is.it", {} }, .{ "isernia.it", {} }, .{ "kr.it", {} }, .{ "la-spezia.it", {} }, .{ "laquila.it", {} }, .{ "laspezia.it", {} }, .{ "latina.it", {} }, .{ "lc.it", {} }, .{ "le.it", {} }, .{ "lecce.it", {} }, .{ "lecco.it", {} }, .{ "li.it", {} }, .{ "livorno.it", {} }, .{ "lo.it", {} }, .{ "lodi.it", {} }, .{ "lt.it", {} }, .{ "lu.it", {} }, .{ "lucca.it", {} }, .{ "macerata.it", {} }, .{ "mantova.it", {} }, .{ "massa-carrara.it", {} }, .{ "massacarrara.it", {} }, .{ "matera.it", {} }, .{ "mb.it", {} }, .{ "mc.it", {} }, .{ "me.it", {} }, .{ "medio-campidano.it", {} }, .{ "mediocampidano.it", {} }, .{ "messina.it", {} }, .{ "mi.it", {} }, .{ "milan.it", {} }, .{ "milano.it", {} }, .{ "mn.it", {} }, .{ "mo.it", {} }, .{ "modena.it", {} }, .{ "monza.it", {} }, .{ "monza-brianza.it", {} }, .{ "monza-e-della-brianza.it", {} }, .{ "monzabrianza.it", {} }, .{ "monzaebrianza.it", {} }, .{ "monzaedellabrianza.it", {} }, .{ "ms.it", {} }, .{ "mt.it", {} }, .{ "na.it", {} }, .{ "naples.it", {} }, .{ "napoli.it", {} }, .{ "no.it", {} }, .{ "novara.it", {} }, .{ "nu.it", {} }, .{ "nuoro.it", {} }, .{ "og.it", {} }, .{ "ogliastra.it", {} }, .{ "olbia-tempio.it", {} }, .{ "olbiatempio.it", {} }, .{ "or.it", {} }, .{ "oristano.it", {} }, .{ "ot.it", {} }, .{ "pa.it", {} }, .{ "padova.it", {} }, .{ "padua.it", {} }, .{ "palermo.it", {} }, .{ "parma.it", {} }, .{ "pavia.it", {} }, .{ "pc.it", {} }, .{ "pd.it", {} }, .{ "pe.it", {} }, .{ "perugia.it", {} }, .{ "pesaro-urbino.it", {} }, .{ "pesarourbino.it", {} }, .{ "pescara.it", {} }, .{ "pg.it", {} }, .{ "pi.it", {} }, .{ "piacenza.it", {} }, .{ "pisa.it", {} }, .{ "pistoia.it", {} }, .{ "pn.it", {} }, .{ "po.it", {} }, .{ "pordenone.it", {} }, .{ "potenza.it", {} }, .{ "pr.it", {} }, .{ "prato.it", {} }, .{ "pt.it", {} }, .{ "pu.it", {} }, .{ "pv.it", {} }, .{ "pz.it", {} }, .{ "ra.it", {} }, .{ "ragusa.it", {} }, .{ "ravenna.it", {} }, .{ "rc.it", {} }, .{ "re.it", {} }, .{ "reggio-calabria.it", {} }, .{ "reggio-emilia.it", {} }, .{ "reggiocalabria.it", {} }, .{ "reggioemilia.it", {} }, .{ "rg.it", {} }, .{ "ri.it", {} }, .{ "rieti.it", {} }, .{ "rimini.it", {} }, .{ "rm.it", {} }, .{ "rn.it", {} }, .{ "ro.it", {} }, .{ "roma.it", {} }, .{ "rome.it", {} }, .{ "rovigo.it", {} }, .{ "sa.it", {} }, .{ "salerno.it", {} }, .{ "sassari.it", {} }, .{ "savona.it", {} }, .{ "si.it", {} }, .{ "siena.it", {} }, .{ "siracusa.it", {} }, .{ "so.it", {} }, .{ "sondrio.it", {} }, .{ "sp.it", {} }, .{ "sr.it", {} }, .{ "ss.it", {} }, .{ "südtirol.it", {} }, .{ "suedtirol.it", {} }, .{ "sv.it", {} }, .{ "ta.it", {} }, .{ "taranto.it", {} }, .{ "te.it", {} }, .{ "tempio-olbia.it", {} }, .{ "tempioolbia.it", {} }, .{ "teramo.it", {} }, .{ "terni.it", {} }, .{ "tn.it", {} }, .{ "to.it", {} }, .{ "torino.it", {} }, .{ "tp.it", {} }, .{ "tr.it", {} }, .{ "trani-andria-barletta.it", {} }, .{ "trani-barletta-andria.it", {} }, .{ "traniandriabarletta.it", {} }, .{ "tranibarlettaandria.it", {} }, .{ "trapani.it", {} }, .{ "trento.it", {} }, .{ "treviso.it", {} }, .{ "trieste.it", {} }, .{ "ts.it", {} }, .{ "turin.it", {} }, .{ "tv.it", {} }, .{ "ud.it", {} }, .{ "udine.it", {} }, .{ "urbino-pesaro.it", {} }, .{ "urbinopesaro.it", {} }, .{ "va.it", {} }, .{ "varese.it", {} }, .{ "vb.it", {} }, .{ "vc.it", {} }, .{ "ve.it", {} }, .{ "venezia.it", {} }, .{ "venice.it", {} }, .{ "verbania.it", {} }, .{ "vercelli.it", {} }, .{ "verona.it", {} }, .{ "vi.it", {} }, .{ "vibo-valentia.it", {} }, .{ "vibovalentia.it", {} }, .{ "vicenza.it", {} }, .{ "viterbo.it", {} }, .{ "vr.it", {} }, .{ "vs.it", {} }, .{ "vt.it", {} }, .{ "vv.it", {} }, .{ "je", {} }, .{ "co.je", {} }, .{ "net.je", {} }, .{ "org.je", {} }, .{ "*.jm", {} }, .{ "jo", {} }, .{ "agri.jo", {} }, .{ "ai.jo", {} }, .{ "com.jo", {} }, .{ "edu.jo", {} }, .{ "eng.jo", {} }, .{ "fm.jo", {} }, .{ "gov.jo", {} }, .{ "mil.jo", {} }, .{ "net.jo", {} }, .{ "org.jo", {} }, .{ "per.jo", {} }, .{ "phd.jo", {} }, .{ "sch.jo", {} }, .{ "tv.jo", {} }, .{ "jobs", {} }, .{ "jp", {} }, .{ "ac.jp", {} }, .{ "ad.jp", {} }, .{ "co.jp", {} }, .{ "ed.jp", {} }, .{ "go.jp", {} }, .{ "gr.jp", {} }, .{ "lg.jp", {} }, .{ "ne.jp", {} }, .{ "or.jp", {} }, .{ "aichi.jp", {} }, .{ "akita.jp", {} }, .{ "aomori.jp", {} }, .{ "chiba.jp", {} }, .{ "ehime.jp", {} }, .{ "fukui.jp", {} }, .{ "fukuoka.jp", {} }, .{ "fukushima.jp", {} }, .{ "gifu.jp", {} }, .{ "gunma.jp", {} }, .{ "hiroshima.jp", {} }, .{ "hokkaido.jp", {} }, .{ "hyogo.jp", {} }, .{ "ibaraki.jp", {} }, .{ "ishikawa.jp", {} }, .{ "iwate.jp", {} }, .{ "kagawa.jp", {} }, .{ "kagoshima.jp", {} }, .{ "kanagawa.jp", {} }, .{ "kochi.jp", {} }, .{ "kumamoto.jp", {} }, .{ "kyoto.jp", {} }, .{ "mie.jp", {} }, .{ "miyagi.jp", {} }, .{ "miyazaki.jp", {} }, .{ "nagano.jp", {} }, .{ "nagasaki.jp", {} }, .{ "nara.jp", {} }, .{ "niigata.jp", {} }, .{ "oita.jp", {} }, .{ "okayama.jp", {} }, .{ "okinawa.jp", {} }, .{ "osaka.jp", {} }, .{ "saga.jp", {} }, .{ "saitama.jp", {} }, .{ "shiga.jp", {} }, .{ "shimane.jp", {} }, .{ "shizuoka.jp", {} }, .{ "tochigi.jp", {} }, .{ "tokushima.jp", {} }, .{ "tokyo.jp", {} }, .{ "tottori.jp", {} }, .{ "toyama.jp", {} }, .{ "wakayama.jp", {} }, .{ "yamagata.jp", {} }, .{ "yamaguchi.jp", {} }, .{ "yamanashi.jp", {} }, .{ "三重.jp", {} }, .{ "京都.jp", {} }, .{ "佐賀.jp", {} }, .{ "兵庫.jp", {} }, .{ "北海道.jp", {} }, .{ "千葉.jp", {} }, .{ "和歌山.jp", {} }, .{ "埼玉.jp", {} }, .{ "大分.jp", {} }, .{ "大阪.jp", {} }, .{ "奈良.jp", {} }, .{ "宮城.jp", {} }, .{ "宮崎.jp", {} }, .{ "富山.jp", {} }, .{ "山口.jp", {} }, .{ "山形.jp", {} }, .{ "山梨.jp", {} }, .{ "岐阜.jp", {} }, .{ "岡山.jp", {} }, .{ "岩手.jp", {} }, .{ "島根.jp", {} }, .{ "広島.jp", {} }, .{ "徳島.jp", {} }, .{ "愛媛.jp", {} }, .{ "愛知.jp", {} }, .{ "新潟.jp", {} }, .{ "東京.jp", {} }, .{ "栃木.jp", {} }, .{ "沖縄.jp", {} }, .{ "滋賀.jp", {} }, .{ "熊本.jp", {} }, .{ "石川.jp", {} }, .{ "神奈川.jp", {} }, .{ "福井.jp", {} }, .{ "福岡.jp", {} }, .{ "福島.jp", {} }, .{ "秋田.jp", {} }, .{ "群馬.jp", {} }, .{ "茨城.jp", {} }, .{ "長崎.jp", {} }, .{ "長野.jp", {} }, .{ "青森.jp", {} }, .{ "静岡.jp", {} }, .{ "香川.jp", {} }, .{ "高知.jp", {} }, .{ "鳥取.jp", {} }, .{ "鹿児島.jp", {} }, .{ "*.kawasaki.jp", {} }, .{ "!city.kawasaki.jp", {} }, .{ "*.kitakyushu.jp", {} }, .{ "!city.kitakyushu.jp", {} }, .{ "*.kobe.jp", {} }, .{ "!city.kobe.jp", {} }, .{ "*.nagoya.jp", {} }, .{ "!city.nagoya.jp", {} }, .{ "*.sapporo.jp", {} }, .{ "!city.sapporo.jp", {} }, .{ "*.sendai.jp", {} }, .{ "!city.sendai.jp", {} }, .{ "*.yokohama.jp", {} }, .{ "!city.yokohama.jp", {} }, .{ "aisai.aichi.jp", {} }, .{ "ama.aichi.jp", {} }, .{ "anjo.aichi.jp", {} }, .{ "asuke.aichi.jp", {} }, .{ "chiryu.aichi.jp", {} }, .{ "chita.aichi.jp", {} }, .{ "fuso.aichi.jp", {} }, .{ "gamagori.aichi.jp", {} }, .{ "handa.aichi.jp", {} }, .{ "hazu.aichi.jp", {} }, .{ "hekinan.aichi.jp", {} }, .{ "higashiura.aichi.jp", {} }, .{ "ichinomiya.aichi.jp", {} }, .{ "inazawa.aichi.jp", {} }, .{ "inuyama.aichi.jp", {} }, .{ "isshiki.aichi.jp", {} }, .{ "iwakura.aichi.jp", {} }, .{ "kanie.aichi.jp", {} }, .{ "kariya.aichi.jp", {} }, .{ "kasugai.aichi.jp", {} }, .{ "kira.aichi.jp", {} }, .{ "kiyosu.aichi.jp", {} }, .{ "komaki.aichi.jp", {} }, .{ "konan.aichi.jp", {} }, .{ "kota.aichi.jp", {} }, .{ "mihama.aichi.jp", {} }, .{ "miyoshi.aichi.jp", {} }, .{ "nishio.aichi.jp", {} }, .{ "nisshin.aichi.jp", {} }, .{ "obu.aichi.jp", {} }, .{ "oguchi.aichi.jp", {} }, .{ "oharu.aichi.jp", {} }, .{ "okazaki.aichi.jp", {} }, .{ "owariasahi.aichi.jp", {} }, .{ "seto.aichi.jp", {} }, .{ "shikatsu.aichi.jp", {} }, .{ "shinshiro.aichi.jp", {} }, .{ "shitara.aichi.jp", {} }, .{ "tahara.aichi.jp", {} }, .{ "takahama.aichi.jp", {} }, .{ "tobishima.aichi.jp", {} }, .{ "toei.aichi.jp", {} }, .{ "togo.aichi.jp", {} }, .{ "tokai.aichi.jp", {} }, .{ "tokoname.aichi.jp", {} }, .{ "toyoake.aichi.jp", {} }, .{ "toyohashi.aichi.jp", {} }, .{ "toyokawa.aichi.jp", {} }, .{ "toyone.aichi.jp", {} }, .{ "toyota.aichi.jp", {} }, .{ "tsushima.aichi.jp", {} }, .{ "yatomi.aichi.jp", {} }, .{ "akita.akita.jp", {} }, .{ "daisen.akita.jp", {} }, .{ "fujisato.akita.jp", {} }, .{ "gojome.akita.jp", {} }, .{ "hachirogata.akita.jp", {} }, .{ "happou.akita.jp", {} }, .{ "higashinaruse.akita.jp", {} }, .{ "honjo.akita.jp", {} }, .{ "honjyo.akita.jp", {} }, .{ "ikawa.akita.jp", {} }, .{ "kamikoani.akita.jp", {} }, .{ "kamioka.akita.jp", {} }, .{ "katagami.akita.jp", {} }, .{ "kazuno.akita.jp", {} }, .{ "kitaakita.akita.jp", {} }, .{ "kosaka.akita.jp", {} }, .{ "kyowa.akita.jp", {} }, .{ "misato.akita.jp", {} }, .{ "mitane.akita.jp", {} }, .{ "moriyoshi.akita.jp", {} }, .{ "nikaho.akita.jp", {} }, .{ "noshiro.akita.jp", {} }, .{ "odate.akita.jp", {} }, .{ "oga.akita.jp", {} }, .{ "ogata.akita.jp", {} }, .{ "semboku.akita.jp", {} }, .{ "yokote.akita.jp", {} }, .{ "yurihonjo.akita.jp", {} }, .{ "aomori.aomori.jp", {} }, .{ "gonohe.aomori.jp", {} }, .{ "hachinohe.aomori.jp", {} }, .{ "hashikami.aomori.jp", {} }, .{ "hiranai.aomori.jp", {} }, .{ "hirosaki.aomori.jp", {} }, .{ "itayanagi.aomori.jp", {} }, .{ "kuroishi.aomori.jp", {} }, .{ "misawa.aomori.jp", {} }, .{ "mutsu.aomori.jp", {} }, .{ "nakadomari.aomori.jp", {} }, .{ "noheji.aomori.jp", {} }, .{ "oirase.aomori.jp", {} }, .{ "owani.aomori.jp", {} }, .{ "rokunohe.aomori.jp", {} }, .{ "sannohe.aomori.jp", {} }, .{ "shichinohe.aomori.jp", {} }, .{ "shingo.aomori.jp", {} }, .{ "takko.aomori.jp", {} }, .{ "towada.aomori.jp", {} }, .{ "tsugaru.aomori.jp", {} }, .{ "tsuruta.aomori.jp", {} }, .{ "abiko.chiba.jp", {} }, .{ "asahi.chiba.jp", {} }, .{ "chonan.chiba.jp", {} }, .{ "chosei.chiba.jp", {} }, .{ "choshi.chiba.jp", {} }, .{ "chuo.chiba.jp", {} }, .{ "funabashi.chiba.jp", {} }, .{ "futtsu.chiba.jp", {} }, .{ "hanamigawa.chiba.jp", {} }, .{ "ichihara.chiba.jp", {} }, .{ "ichikawa.chiba.jp", {} }, .{ "ichinomiya.chiba.jp", {} }, .{ "inzai.chiba.jp", {} }, .{ "isumi.chiba.jp", {} }, .{ "kamagaya.chiba.jp", {} }, .{ "kamogawa.chiba.jp", {} }, .{ "kashiwa.chiba.jp", {} }, .{ "katori.chiba.jp", {} }, .{ "katsuura.chiba.jp", {} }, .{ "kimitsu.chiba.jp", {} }, .{ "kisarazu.chiba.jp", {} }, .{ "kozaki.chiba.jp", {} }, .{ "kujukuri.chiba.jp", {} }, .{ "kyonan.chiba.jp", {} }, .{ "matsudo.chiba.jp", {} }, .{ "midori.chiba.jp", {} }, .{ "mihama.chiba.jp", {} }, .{ "minamiboso.chiba.jp", {} }, .{ "mobara.chiba.jp", {} }, .{ "mutsuzawa.chiba.jp", {} }, .{ "nagara.chiba.jp", {} }, .{ "nagareyama.chiba.jp", {} }, .{ "narashino.chiba.jp", {} }, .{ "narita.chiba.jp", {} }, .{ "noda.chiba.jp", {} }, .{ "oamishirasato.chiba.jp", {} }, .{ "omigawa.chiba.jp", {} }, .{ "onjuku.chiba.jp", {} }, .{ "otaki.chiba.jp", {} }, .{ "sakae.chiba.jp", {} }, .{ "sakura.chiba.jp", {} }, .{ "shimofusa.chiba.jp", {} }, .{ "shirako.chiba.jp", {} }, .{ "shiroi.chiba.jp", {} }, .{ "shisui.chiba.jp", {} }, .{ "sodegaura.chiba.jp", {} }, .{ "sosa.chiba.jp", {} }, .{ "tako.chiba.jp", {} }, .{ "tateyama.chiba.jp", {} }, .{ "togane.chiba.jp", {} }, .{ "tohnosho.chiba.jp", {} }, .{ "tomisato.chiba.jp", {} }, .{ "urayasu.chiba.jp", {} }, .{ "yachimata.chiba.jp", {} }, .{ "yachiyo.chiba.jp", {} }, .{ "yokaichiba.chiba.jp", {} }, .{ "yokoshibahikari.chiba.jp", {} }, .{ "yotsukaido.chiba.jp", {} }, .{ "ainan.ehime.jp", {} }, .{ "honai.ehime.jp", {} }, .{ "ikata.ehime.jp", {} }, .{ "imabari.ehime.jp", {} }, .{ "iyo.ehime.jp", {} }, .{ "kamijima.ehime.jp", {} }, .{ "kihoku.ehime.jp", {} }, .{ "kumakogen.ehime.jp", {} }, .{ "masaki.ehime.jp", {} }, .{ "matsuno.ehime.jp", {} }, .{ "matsuyama.ehime.jp", {} }, .{ "namikata.ehime.jp", {} }, .{ "niihama.ehime.jp", {} }, .{ "ozu.ehime.jp", {} }, .{ "saijo.ehime.jp", {} }, .{ "seiyo.ehime.jp", {} }, .{ "shikokuchuo.ehime.jp", {} }, .{ "tobe.ehime.jp", {} }, .{ "toon.ehime.jp", {} }, .{ "uchiko.ehime.jp", {} }, .{ "uwajima.ehime.jp", {} }, .{ "yawatahama.ehime.jp", {} }, .{ "echizen.fukui.jp", {} }, .{ "eiheiji.fukui.jp", {} }, .{ "fukui.fukui.jp", {} }, .{ "ikeda.fukui.jp", {} }, .{ "katsuyama.fukui.jp", {} }, .{ "mihama.fukui.jp", {} }, .{ "minamiechizen.fukui.jp", {} }, .{ "obama.fukui.jp", {} }, .{ "ohi.fukui.jp", {} }, .{ "ono.fukui.jp", {} }, .{ "sabae.fukui.jp", {} }, .{ "sakai.fukui.jp", {} }, .{ "takahama.fukui.jp", {} }, .{ "tsuruga.fukui.jp", {} }, .{ "wakasa.fukui.jp", {} }, .{ "ashiya.fukuoka.jp", {} }, .{ "buzen.fukuoka.jp", {} }, .{ "chikugo.fukuoka.jp", {} }, .{ "chikuho.fukuoka.jp", {} }, .{ "chikujo.fukuoka.jp", {} }, .{ "chikushino.fukuoka.jp", {} }, .{ "chikuzen.fukuoka.jp", {} }, .{ "chuo.fukuoka.jp", {} }, .{ "dazaifu.fukuoka.jp", {} }, .{ "fukuchi.fukuoka.jp", {} }, .{ "hakata.fukuoka.jp", {} }, .{ "higashi.fukuoka.jp", {} }, .{ "hirokawa.fukuoka.jp", {} }, .{ "hisayama.fukuoka.jp", {} }, .{ "iizuka.fukuoka.jp", {} }, .{ "inatsuki.fukuoka.jp", {} }, .{ "kaho.fukuoka.jp", {} }, .{ "kasuga.fukuoka.jp", {} }, .{ "kasuya.fukuoka.jp", {} }, .{ "kawara.fukuoka.jp", {} }, .{ "keisen.fukuoka.jp", {} }, .{ "koga.fukuoka.jp", {} }, .{ "kurate.fukuoka.jp", {} }, .{ "kurogi.fukuoka.jp", {} }, .{ "kurume.fukuoka.jp", {} }, .{ "minami.fukuoka.jp", {} }, .{ "miyako.fukuoka.jp", {} }, .{ "miyama.fukuoka.jp", {} }, .{ "miyawaka.fukuoka.jp", {} }, .{ "mizumaki.fukuoka.jp", {} }, .{ "munakata.fukuoka.jp", {} }, .{ "nakagawa.fukuoka.jp", {} }, .{ "nakama.fukuoka.jp", {} }, .{ "nishi.fukuoka.jp", {} }, .{ "nogata.fukuoka.jp", {} }, .{ "ogori.fukuoka.jp", {} }, .{ "okagaki.fukuoka.jp", {} }, .{ "okawa.fukuoka.jp", {} }, .{ "oki.fukuoka.jp", {} }, .{ "omuta.fukuoka.jp", {} }, .{ "onga.fukuoka.jp", {} }, .{ "onojo.fukuoka.jp", {} }, .{ "oto.fukuoka.jp", {} }, .{ "saigawa.fukuoka.jp", {} }, .{ "sasaguri.fukuoka.jp", {} }, .{ "shingu.fukuoka.jp", {} }, .{ "shinyoshitomi.fukuoka.jp", {} }, .{ "shonai.fukuoka.jp", {} }, .{ "soeda.fukuoka.jp", {} }, .{ "sue.fukuoka.jp", {} }, .{ "tachiarai.fukuoka.jp", {} }, .{ "tagawa.fukuoka.jp", {} }, .{ "takata.fukuoka.jp", {} }, .{ "toho.fukuoka.jp", {} }, .{ "toyotsu.fukuoka.jp", {} }, .{ "tsuiki.fukuoka.jp", {} }, .{ "ukiha.fukuoka.jp", {} }, .{ "umi.fukuoka.jp", {} }, .{ "usui.fukuoka.jp", {} }, .{ "yamada.fukuoka.jp", {} }, .{ "yame.fukuoka.jp", {} }, .{ "yanagawa.fukuoka.jp", {} }, .{ "yukuhashi.fukuoka.jp", {} }, .{ "aizubange.fukushima.jp", {} }, .{ "aizumisato.fukushima.jp", {} }, .{ "aizuwakamatsu.fukushima.jp", {} }, .{ "asakawa.fukushima.jp", {} }, .{ "bandai.fukushima.jp", {} }, .{ "date.fukushima.jp", {} }, .{ "fukushima.fukushima.jp", {} }, .{ "furudono.fukushima.jp", {} }, .{ "futaba.fukushima.jp", {} }, .{ "hanawa.fukushima.jp", {} }, .{ "higashi.fukushima.jp", {} }, .{ "hirata.fukushima.jp", {} }, .{ "hirono.fukushima.jp", {} }, .{ "iitate.fukushima.jp", {} }, .{ "inawashiro.fukushima.jp", {} }, .{ "ishikawa.fukushima.jp", {} }, .{ "iwaki.fukushima.jp", {} }, .{ "izumizaki.fukushima.jp", {} }, .{ "kagamiishi.fukushima.jp", {} }, .{ "kaneyama.fukushima.jp", {} }, .{ "kawamata.fukushima.jp", {} }, .{ "kitakata.fukushima.jp", {} }, .{ "kitashiobara.fukushima.jp", {} }, .{ "koori.fukushima.jp", {} }, .{ "koriyama.fukushima.jp", {} }, .{ "kunimi.fukushima.jp", {} }, .{ "miharu.fukushima.jp", {} }, .{ "mishima.fukushima.jp", {} }, .{ "namie.fukushima.jp", {} }, .{ "nango.fukushima.jp", {} }, .{ "nishiaizu.fukushima.jp", {} }, .{ "nishigo.fukushima.jp", {} }, .{ "okuma.fukushima.jp", {} }, .{ "omotego.fukushima.jp", {} }, .{ "ono.fukushima.jp", {} }, .{ "otama.fukushima.jp", {} }, .{ "samegawa.fukushima.jp", {} }, .{ "shimogo.fukushima.jp", {} }, .{ "shirakawa.fukushima.jp", {} }, .{ "showa.fukushima.jp", {} }, .{ "soma.fukushima.jp", {} }, .{ "sukagawa.fukushima.jp", {} }, .{ "taishin.fukushima.jp", {} }, .{ "tamakawa.fukushima.jp", {} }, .{ "tanagura.fukushima.jp", {} }, .{ "tenei.fukushima.jp", {} }, .{ "yabuki.fukushima.jp", {} }, .{ "yamato.fukushima.jp", {} }, .{ "yamatsuri.fukushima.jp", {} }, .{ "yanaizu.fukushima.jp", {} }, .{ "yugawa.fukushima.jp", {} }, .{ "anpachi.gifu.jp", {} }, .{ "ena.gifu.jp", {} }, .{ "gifu.gifu.jp", {} }, .{ "ginan.gifu.jp", {} }, .{ "godo.gifu.jp", {} }, .{ "gujo.gifu.jp", {} }, .{ "hashima.gifu.jp", {} }, .{ "hichiso.gifu.jp", {} }, .{ "hida.gifu.jp", {} }, .{ "higashishirakawa.gifu.jp", {} }, .{ "ibigawa.gifu.jp", {} }, .{ "ikeda.gifu.jp", {} }, .{ "kakamigahara.gifu.jp", {} }, .{ "kani.gifu.jp", {} }, .{ "kasahara.gifu.jp", {} }, .{ "kasamatsu.gifu.jp", {} }, .{ "kawaue.gifu.jp", {} }, .{ "kitagata.gifu.jp", {} }, .{ "mino.gifu.jp", {} }, .{ "minokamo.gifu.jp", {} }, .{ "mitake.gifu.jp", {} }, .{ "mizunami.gifu.jp", {} }, .{ "motosu.gifu.jp", {} }, .{ "nakatsugawa.gifu.jp", {} }, .{ "ogaki.gifu.jp", {} }, .{ "sakahogi.gifu.jp", {} }, .{ "seki.gifu.jp", {} }, .{ "sekigahara.gifu.jp", {} }, .{ "shirakawa.gifu.jp", {} }, .{ "tajimi.gifu.jp", {} }, .{ "takayama.gifu.jp", {} }, .{ "tarui.gifu.jp", {} }, .{ "toki.gifu.jp", {} }, .{ "tomika.gifu.jp", {} }, .{ "wanouchi.gifu.jp", {} }, .{ "yamagata.gifu.jp", {} }, .{ "yaotsu.gifu.jp", {} }, .{ "yoro.gifu.jp", {} }, .{ "annaka.gunma.jp", {} }, .{ "chiyoda.gunma.jp", {} }, .{ "fujioka.gunma.jp", {} }, .{ "higashiagatsuma.gunma.jp", {} }, .{ "isesaki.gunma.jp", {} }, .{ "itakura.gunma.jp", {} }, .{ "kanna.gunma.jp", {} }, .{ "kanra.gunma.jp", {} }, .{ "katashina.gunma.jp", {} }, .{ "kawaba.gunma.jp", {} }, .{ "kiryu.gunma.jp", {} }, .{ "kusatsu.gunma.jp", {} }, .{ "maebashi.gunma.jp", {} }, .{ "meiwa.gunma.jp", {} }, .{ "midori.gunma.jp", {} }, .{ "minakami.gunma.jp", {} }, .{ "naganohara.gunma.jp", {} }, .{ "nakanojo.gunma.jp", {} }, .{ "nanmoku.gunma.jp", {} }, .{ "numata.gunma.jp", {} }, .{ "oizumi.gunma.jp", {} }, .{ "ora.gunma.jp", {} }, .{ "ota.gunma.jp", {} }, .{ "shibukawa.gunma.jp", {} }, .{ "shimonita.gunma.jp", {} }, .{ "shinto.gunma.jp", {} }, .{ "showa.gunma.jp", {} }, .{ "takasaki.gunma.jp", {} }, .{ "takayama.gunma.jp", {} }, .{ "tamamura.gunma.jp", {} }, .{ "tatebayashi.gunma.jp", {} }, .{ "tomioka.gunma.jp", {} }, .{ "tsukiyono.gunma.jp", {} }, .{ "tsumagoi.gunma.jp", {} }, .{ "ueno.gunma.jp", {} }, .{ "yoshioka.gunma.jp", {} }, .{ "asaminami.hiroshima.jp", {} }, .{ "daiwa.hiroshima.jp", {} }, .{ "etajima.hiroshima.jp", {} }, .{ "fuchu.hiroshima.jp", {} }, .{ "fukuyama.hiroshima.jp", {} }, .{ "hatsukaichi.hiroshima.jp", {} }, .{ "higashihiroshima.hiroshima.jp", {} }, .{ "hongo.hiroshima.jp", {} }, .{ "jinsekikogen.hiroshima.jp", {} }, .{ "kaita.hiroshima.jp", {} }, .{ "kui.hiroshima.jp", {} }, .{ "kumano.hiroshima.jp", {} }, .{ "kure.hiroshima.jp", {} }, .{ "mihara.hiroshima.jp", {} }, .{ "miyoshi.hiroshima.jp", {} }, .{ "naka.hiroshima.jp", {} }, .{ "onomichi.hiroshima.jp", {} }, .{ "osakikamijima.hiroshima.jp", {} }, .{ "otake.hiroshima.jp", {} }, .{ "saka.hiroshima.jp", {} }, .{ "sera.hiroshima.jp", {} }, .{ "seranishi.hiroshima.jp", {} }, .{ "shinichi.hiroshima.jp", {} }, .{ "shobara.hiroshima.jp", {} }, .{ "takehara.hiroshima.jp", {} }, .{ "abashiri.hokkaido.jp", {} }, .{ "abira.hokkaido.jp", {} }, .{ "aibetsu.hokkaido.jp", {} }, .{ "akabira.hokkaido.jp", {} }, .{ "akkeshi.hokkaido.jp", {} }, .{ "asahikawa.hokkaido.jp", {} }, .{ "ashibetsu.hokkaido.jp", {} }, .{ "ashoro.hokkaido.jp", {} }, .{ "assabu.hokkaido.jp", {} }, .{ "atsuma.hokkaido.jp", {} }, .{ "bibai.hokkaido.jp", {} }, .{ "biei.hokkaido.jp", {} }, .{ "bifuka.hokkaido.jp", {} }, .{ "bihoro.hokkaido.jp", {} }, .{ "biratori.hokkaido.jp", {} }, .{ "chippubetsu.hokkaido.jp", {} }, .{ "chitose.hokkaido.jp", {} }, .{ "date.hokkaido.jp", {} }, .{ "ebetsu.hokkaido.jp", {} }, .{ "embetsu.hokkaido.jp", {} }, .{ "eniwa.hokkaido.jp", {} }, .{ "erimo.hokkaido.jp", {} }, .{ "esan.hokkaido.jp", {} }, .{ "esashi.hokkaido.jp", {} }, .{ "fukagawa.hokkaido.jp", {} }, .{ "fukushima.hokkaido.jp", {} }, .{ "furano.hokkaido.jp", {} }, .{ "furubira.hokkaido.jp", {} }, .{ "haboro.hokkaido.jp", {} }, .{ "hakodate.hokkaido.jp", {} }, .{ "hamatonbetsu.hokkaido.jp", {} }, .{ "hidaka.hokkaido.jp", {} }, .{ "higashikagura.hokkaido.jp", {} }, .{ "higashikawa.hokkaido.jp", {} }, .{ "hiroo.hokkaido.jp", {} }, .{ "hokuryu.hokkaido.jp", {} }, .{ "hokuto.hokkaido.jp", {} }, .{ "honbetsu.hokkaido.jp", {} }, .{ "horokanai.hokkaido.jp", {} }, .{ "horonobe.hokkaido.jp", {} }, .{ "ikeda.hokkaido.jp", {} }, .{ "imakane.hokkaido.jp", {} }, .{ "ishikari.hokkaido.jp", {} }, .{ "iwamizawa.hokkaido.jp", {} }, .{ "iwanai.hokkaido.jp", {} }, .{ "kamifurano.hokkaido.jp", {} }, .{ "kamikawa.hokkaido.jp", {} }, .{ "kamishihoro.hokkaido.jp", {} }, .{ "kamisunagawa.hokkaido.jp", {} }, .{ "kamoenai.hokkaido.jp", {} }, .{ "kayabe.hokkaido.jp", {} }, .{ "kembuchi.hokkaido.jp", {} }, .{ "kikonai.hokkaido.jp", {} }, .{ "kimobetsu.hokkaido.jp", {} }, .{ "kitahiroshima.hokkaido.jp", {} }, .{ "kitami.hokkaido.jp", {} }, .{ "kiyosato.hokkaido.jp", {} }, .{ "koshimizu.hokkaido.jp", {} }, .{ "kunneppu.hokkaido.jp", {} }, .{ "kuriyama.hokkaido.jp", {} }, .{ "kuromatsunai.hokkaido.jp", {} }, .{ "kushiro.hokkaido.jp", {} }, .{ "kutchan.hokkaido.jp", {} }, .{ "kyowa.hokkaido.jp", {} }, .{ "mashike.hokkaido.jp", {} }, .{ "matsumae.hokkaido.jp", {} }, .{ "mikasa.hokkaido.jp", {} }, .{ "minamifurano.hokkaido.jp", {} }, .{ "mombetsu.hokkaido.jp", {} }, .{ "moseushi.hokkaido.jp", {} }, .{ "mukawa.hokkaido.jp", {} }, .{ "muroran.hokkaido.jp", {} }, .{ "naie.hokkaido.jp", {} }, .{ "nakagawa.hokkaido.jp", {} }, .{ "nakasatsunai.hokkaido.jp", {} }, .{ "nakatombetsu.hokkaido.jp", {} }, .{ "nanae.hokkaido.jp", {} }, .{ "nanporo.hokkaido.jp", {} }, .{ "nayoro.hokkaido.jp", {} }, .{ "nemuro.hokkaido.jp", {} }, .{ "niikappu.hokkaido.jp", {} }, .{ "niki.hokkaido.jp", {} }, .{ "nishiokoppe.hokkaido.jp", {} }, .{ "noboribetsu.hokkaido.jp", {} }, .{ "numata.hokkaido.jp", {} }, .{ "obihiro.hokkaido.jp", {} }, .{ "obira.hokkaido.jp", {} }, .{ "oketo.hokkaido.jp", {} }, .{ "okoppe.hokkaido.jp", {} }, .{ "otaru.hokkaido.jp", {} }, .{ "otobe.hokkaido.jp", {} }, .{ "otofuke.hokkaido.jp", {} }, .{ "otoineppu.hokkaido.jp", {} }, .{ "oumu.hokkaido.jp", {} }, .{ "ozora.hokkaido.jp", {} }, .{ "pippu.hokkaido.jp", {} }, .{ "rankoshi.hokkaido.jp", {} }, .{ "rebun.hokkaido.jp", {} }, .{ "rikubetsu.hokkaido.jp", {} }, .{ "rishiri.hokkaido.jp", {} }, .{ "rishirifuji.hokkaido.jp", {} }, .{ "saroma.hokkaido.jp", {} }, .{ "sarufutsu.hokkaido.jp", {} }, .{ "shakotan.hokkaido.jp", {} }, .{ "shari.hokkaido.jp", {} }, .{ "shibecha.hokkaido.jp", {} }, .{ "shibetsu.hokkaido.jp", {} }, .{ "shikabe.hokkaido.jp", {} }, .{ "shikaoi.hokkaido.jp", {} }, .{ "shimamaki.hokkaido.jp", {} }, .{ "shimizu.hokkaido.jp", {} }, .{ "shimokawa.hokkaido.jp", {} }, .{ "shinshinotsu.hokkaido.jp", {} }, .{ "shintoku.hokkaido.jp", {} }, .{ "shiranuka.hokkaido.jp", {} }, .{ "shiraoi.hokkaido.jp", {} }, .{ "shiriuchi.hokkaido.jp", {} }, .{ "sobetsu.hokkaido.jp", {} }, .{ "sunagawa.hokkaido.jp", {} }, .{ "taiki.hokkaido.jp", {} }, .{ "takasu.hokkaido.jp", {} }, .{ "takikawa.hokkaido.jp", {} }, .{ "takinoue.hokkaido.jp", {} }, .{ "teshikaga.hokkaido.jp", {} }, .{ "tobetsu.hokkaido.jp", {} }, .{ "tohma.hokkaido.jp", {} }, .{ "tomakomai.hokkaido.jp", {} }, .{ "tomari.hokkaido.jp", {} }, .{ "toya.hokkaido.jp", {} }, .{ "toyako.hokkaido.jp", {} }, .{ "toyotomi.hokkaido.jp", {} }, .{ "toyoura.hokkaido.jp", {} }, .{ "tsubetsu.hokkaido.jp", {} }, .{ "tsukigata.hokkaido.jp", {} }, .{ "urakawa.hokkaido.jp", {} }, .{ "urausu.hokkaido.jp", {} }, .{ "uryu.hokkaido.jp", {} }, .{ "utashinai.hokkaido.jp", {} }, .{ "wakkanai.hokkaido.jp", {} }, .{ "wassamu.hokkaido.jp", {} }, .{ "yakumo.hokkaido.jp", {} }, .{ "yoichi.hokkaido.jp", {} }, .{ "aioi.hyogo.jp", {} }, .{ "akashi.hyogo.jp", {} }, .{ "ako.hyogo.jp", {} }, .{ "amagasaki.hyogo.jp", {} }, .{ "aogaki.hyogo.jp", {} }, .{ "asago.hyogo.jp", {} }, .{ "ashiya.hyogo.jp", {} }, .{ "awaji.hyogo.jp", {} }, .{ "fukusaki.hyogo.jp", {} }, .{ "goshiki.hyogo.jp", {} }, .{ "harima.hyogo.jp", {} }, .{ "himeji.hyogo.jp", {} }, .{ "ichikawa.hyogo.jp", {} }, .{ "inagawa.hyogo.jp", {} }, .{ "itami.hyogo.jp", {} }, .{ "kakogawa.hyogo.jp", {} }, .{ "kamigori.hyogo.jp", {} }, .{ "kamikawa.hyogo.jp", {} }, .{ "kasai.hyogo.jp", {} }, .{ "kasuga.hyogo.jp", {} }, .{ "kawanishi.hyogo.jp", {} }, .{ "miki.hyogo.jp", {} }, .{ "minamiawaji.hyogo.jp", {} }, .{ "nishinomiya.hyogo.jp", {} }, .{ "nishiwaki.hyogo.jp", {} }, .{ "ono.hyogo.jp", {} }, .{ "sanda.hyogo.jp", {} }, .{ "sannan.hyogo.jp", {} }, .{ "sasayama.hyogo.jp", {} }, .{ "sayo.hyogo.jp", {} }, .{ "shingu.hyogo.jp", {} }, .{ "shinonsen.hyogo.jp", {} }, .{ "shiso.hyogo.jp", {} }, .{ "sumoto.hyogo.jp", {} }, .{ "taishi.hyogo.jp", {} }, .{ "taka.hyogo.jp", {} }, .{ "takarazuka.hyogo.jp", {} }, .{ "takasago.hyogo.jp", {} }, .{ "takino.hyogo.jp", {} }, .{ "tamba.hyogo.jp", {} }, .{ "tatsuno.hyogo.jp", {} }, .{ "toyooka.hyogo.jp", {} }, .{ "yabu.hyogo.jp", {} }, .{ "yashiro.hyogo.jp", {} }, .{ "yoka.hyogo.jp", {} }, .{ "yokawa.hyogo.jp", {} }, .{ "ami.ibaraki.jp", {} }, .{ "asahi.ibaraki.jp", {} }, .{ "bando.ibaraki.jp", {} }, .{ "chikusei.ibaraki.jp", {} }, .{ "daigo.ibaraki.jp", {} }, .{ "fujishiro.ibaraki.jp", {} }, .{ "hitachi.ibaraki.jp", {} }, .{ "hitachinaka.ibaraki.jp", {} }, .{ "hitachiomiya.ibaraki.jp", {} }, .{ "hitachiota.ibaraki.jp", {} }, .{ "ibaraki.ibaraki.jp", {} }, .{ "ina.ibaraki.jp", {} }, .{ "inashiki.ibaraki.jp", {} }, .{ "itako.ibaraki.jp", {} }, .{ "iwama.ibaraki.jp", {} }, .{ "joso.ibaraki.jp", {} }, .{ "kamisu.ibaraki.jp", {} }, .{ "kasama.ibaraki.jp", {} }, .{ "kashima.ibaraki.jp", {} }, .{ "kasumigaura.ibaraki.jp", {} }, .{ "koga.ibaraki.jp", {} }, .{ "miho.ibaraki.jp", {} }, .{ "mito.ibaraki.jp", {} }, .{ "moriya.ibaraki.jp", {} }, .{ "naka.ibaraki.jp", {} }, .{ "namegata.ibaraki.jp", {} }, .{ "oarai.ibaraki.jp", {} }, .{ "ogawa.ibaraki.jp", {} }, .{ "omitama.ibaraki.jp", {} }, .{ "ryugasaki.ibaraki.jp", {} }, .{ "sakai.ibaraki.jp", {} }, .{ "sakuragawa.ibaraki.jp", {} }, .{ "shimodate.ibaraki.jp", {} }, .{ "shimotsuma.ibaraki.jp", {} }, .{ "shirosato.ibaraki.jp", {} }, .{ "sowa.ibaraki.jp", {} }, .{ "suifu.ibaraki.jp", {} }, .{ "takahagi.ibaraki.jp", {} }, .{ "tamatsukuri.ibaraki.jp", {} }, .{ "tokai.ibaraki.jp", {} }, .{ "tomobe.ibaraki.jp", {} }, .{ "tone.ibaraki.jp", {} }, .{ "toride.ibaraki.jp", {} }, .{ "tsuchiura.ibaraki.jp", {} }, .{ "tsukuba.ibaraki.jp", {} }, .{ "uchihara.ibaraki.jp", {} }, .{ "ushiku.ibaraki.jp", {} }, .{ "yachiyo.ibaraki.jp", {} }, .{ "yamagata.ibaraki.jp", {} }, .{ "yawara.ibaraki.jp", {} }, .{ "yuki.ibaraki.jp", {} }, .{ "anamizu.ishikawa.jp", {} }, .{ "hakui.ishikawa.jp", {} }, .{ "hakusan.ishikawa.jp", {} }, .{ "kaga.ishikawa.jp", {} }, .{ "kahoku.ishikawa.jp", {} }, .{ "kanazawa.ishikawa.jp", {} }, .{ "kawakita.ishikawa.jp", {} }, .{ "komatsu.ishikawa.jp", {} }, .{ "nakanoto.ishikawa.jp", {} }, .{ "nanao.ishikawa.jp", {} }, .{ "nomi.ishikawa.jp", {} }, .{ "nonoichi.ishikawa.jp", {} }, .{ "noto.ishikawa.jp", {} }, .{ "shika.ishikawa.jp", {} }, .{ "suzu.ishikawa.jp", {} }, .{ "tsubata.ishikawa.jp", {} }, .{ "tsurugi.ishikawa.jp", {} }, .{ "uchinada.ishikawa.jp", {} }, .{ "wajima.ishikawa.jp", {} }, .{ "fudai.iwate.jp", {} }, .{ "fujisawa.iwate.jp", {} }, .{ "hanamaki.iwate.jp", {} }, .{ "hiraizumi.iwate.jp", {} }, .{ "hirono.iwate.jp", {} }, .{ "ichinohe.iwate.jp", {} }, .{ "ichinoseki.iwate.jp", {} }, .{ "iwaizumi.iwate.jp", {} }, .{ "iwate.iwate.jp", {} }, .{ "joboji.iwate.jp", {} }, .{ "kamaishi.iwate.jp", {} }, .{ "kanegasaki.iwate.jp", {} }, .{ "karumai.iwate.jp", {} }, .{ "kawai.iwate.jp", {} }, .{ "kitakami.iwate.jp", {} }, .{ "kuji.iwate.jp", {} }, .{ "kunohe.iwate.jp", {} }, .{ "kuzumaki.iwate.jp", {} }, .{ "miyako.iwate.jp", {} }, .{ "mizusawa.iwate.jp", {} }, .{ "morioka.iwate.jp", {} }, .{ "ninohe.iwate.jp", {} }, .{ "noda.iwate.jp", {} }, .{ "ofunato.iwate.jp", {} }, .{ "oshu.iwate.jp", {} }, .{ "otsuchi.iwate.jp", {} }, .{ "rikuzentakata.iwate.jp", {} }, .{ "shiwa.iwate.jp", {} }, .{ "shizukuishi.iwate.jp", {} }, .{ "sumita.iwate.jp", {} }, .{ "tanohata.iwate.jp", {} }, .{ "tono.iwate.jp", {} }, .{ "yahaba.iwate.jp", {} }, .{ "yamada.iwate.jp", {} }, .{ "ayagawa.kagawa.jp", {} }, .{ "higashikagawa.kagawa.jp", {} }, .{ "kanonji.kagawa.jp", {} }, .{ "kotohira.kagawa.jp", {} }, .{ "manno.kagawa.jp", {} }, .{ "marugame.kagawa.jp", {} }, .{ "mitoyo.kagawa.jp", {} }, .{ "naoshima.kagawa.jp", {} }, .{ "sanuki.kagawa.jp", {} }, .{ "tadotsu.kagawa.jp", {} }, .{ "takamatsu.kagawa.jp", {} }, .{ "tonosho.kagawa.jp", {} }, .{ "uchinomi.kagawa.jp", {} }, .{ "utazu.kagawa.jp", {} }, .{ "zentsuji.kagawa.jp", {} }, .{ "akune.kagoshima.jp", {} }, .{ "amami.kagoshima.jp", {} }, .{ "hioki.kagoshima.jp", {} }, .{ "isa.kagoshima.jp", {} }, .{ "isen.kagoshima.jp", {} }, .{ "izumi.kagoshima.jp", {} }, .{ "kagoshima.kagoshima.jp", {} }, .{ "kanoya.kagoshima.jp", {} }, .{ "kawanabe.kagoshima.jp", {} }, .{ "kinko.kagoshima.jp", {} }, .{ "kouyama.kagoshima.jp", {} }, .{ "makurazaki.kagoshima.jp", {} }, .{ "matsumoto.kagoshima.jp", {} }, .{ "minamitane.kagoshima.jp", {} }, .{ "nakatane.kagoshima.jp", {} }, .{ "nishinoomote.kagoshima.jp", {} }, .{ "satsumasendai.kagoshima.jp", {} }, .{ "soo.kagoshima.jp", {} }, .{ "tarumizu.kagoshima.jp", {} }, .{ "yusui.kagoshima.jp", {} }, .{ "aikawa.kanagawa.jp", {} }, .{ "atsugi.kanagawa.jp", {} }, .{ "ayase.kanagawa.jp", {} }, .{ "chigasaki.kanagawa.jp", {} }, .{ "ebina.kanagawa.jp", {} }, .{ "fujisawa.kanagawa.jp", {} }, .{ "hadano.kanagawa.jp", {} }, .{ "hakone.kanagawa.jp", {} }, .{ "hiratsuka.kanagawa.jp", {} }, .{ "isehara.kanagawa.jp", {} }, .{ "kaisei.kanagawa.jp", {} }, .{ "kamakura.kanagawa.jp", {} }, .{ "kiyokawa.kanagawa.jp", {} }, .{ "matsuda.kanagawa.jp", {} }, .{ "minamiashigara.kanagawa.jp", {} }, .{ "miura.kanagawa.jp", {} }, .{ "nakai.kanagawa.jp", {} }, .{ "ninomiya.kanagawa.jp", {} }, .{ "odawara.kanagawa.jp", {} }, .{ "oi.kanagawa.jp", {} }, .{ "oiso.kanagawa.jp", {} }, .{ "sagamihara.kanagawa.jp", {} }, .{ "samukawa.kanagawa.jp", {} }, .{ "tsukui.kanagawa.jp", {} }, .{ "yamakita.kanagawa.jp", {} }, .{ "yamato.kanagawa.jp", {} }, .{ "yokosuka.kanagawa.jp", {} }, .{ "yugawara.kanagawa.jp", {} }, .{ "zama.kanagawa.jp", {} }, .{ "zushi.kanagawa.jp", {} }, .{ "aki.kochi.jp", {} }, .{ "geisei.kochi.jp", {} }, .{ "hidaka.kochi.jp", {} }, .{ "higashitsuno.kochi.jp", {} }, .{ "ino.kochi.jp", {} }, .{ "kagami.kochi.jp", {} }, .{ "kami.kochi.jp", {} }, .{ "kitagawa.kochi.jp", {} }, .{ "kochi.kochi.jp", {} }, .{ "mihara.kochi.jp", {} }, .{ "motoyama.kochi.jp", {} }, .{ "muroto.kochi.jp", {} }, .{ "nahari.kochi.jp", {} }, .{ "nakamura.kochi.jp", {} }, .{ "nankoku.kochi.jp", {} }, .{ "nishitosa.kochi.jp", {} }, .{ "niyodogawa.kochi.jp", {} }, .{ "ochi.kochi.jp", {} }, .{ "okawa.kochi.jp", {} }, .{ "otoyo.kochi.jp", {} }, .{ "otsuki.kochi.jp", {} }, .{ "sakawa.kochi.jp", {} }, .{ "sukumo.kochi.jp", {} }, .{ "susaki.kochi.jp", {} }, .{ "tosa.kochi.jp", {} }, .{ "tosashimizu.kochi.jp", {} }, .{ "toyo.kochi.jp", {} }, .{ "tsuno.kochi.jp", {} }, .{ "umaji.kochi.jp", {} }, .{ "yasuda.kochi.jp", {} }, .{ "yusuhara.kochi.jp", {} }, .{ "amakusa.kumamoto.jp", {} }, .{ "arao.kumamoto.jp", {} }, .{ "aso.kumamoto.jp", {} }, .{ "choyo.kumamoto.jp", {} }, .{ "gyokuto.kumamoto.jp", {} }, .{ "kamiamakusa.kumamoto.jp", {} }, .{ "kikuchi.kumamoto.jp", {} }, .{ "kumamoto.kumamoto.jp", {} }, .{ "mashiki.kumamoto.jp", {} }, .{ "mifune.kumamoto.jp", {} }, .{ "minamata.kumamoto.jp", {} }, .{ "minamioguni.kumamoto.jp", {} }, .{ "nagasu.kumamoto.jp", {} }, .{ "nishihara.kumamoto.jp", {} }, .{ "oguni.kumamoto.jp", {} }, .{ "ozu.kumamoto.jp", {} }, .{ "sumoto.kumamoto.jp", {} }, .{ "takamori.kumamoto.jp", {} }, .{ "uki.kumamoto.jp", {} }, .{ "uto.kumamoto.jp", {} }, .{ "yamaga.kumamoto.jp", {} }, .{ "yamato.kumamoto.jp", {} }, .{ "yatsushiro.kumamoto.jp", {} }, .{ "ayabe.kyoto.jp", {} }, .{ "fukuchiyama.kyoto.jp", {} }, .{ "higashiyama.kyoto.jp", {} }, .{ "ide.kyoto.jp", {} }, .{ "ine.kyoto.jp", {} }, .{ "joyo.kyoto.jp", {} }, .{ "kameoka.kyoto.jp", {} }, .{ "kamo.kyoto.jp", {} }, .{ "kita.kyoto.jp", {} }, .{ "kizu.kyoto.jp", {} }, .{ "kumiyama.kyoto.jp", {} }, .{ "kyotamba.kyoto.jp", {} }, .{ "kyotanabe.kyoto.jp", {} }, .{ "kyotango.kyoto.jp", {} }, .{ "maizuru.kyoto.jp", {} }, .{ "minami.kyoto.jp", {} }, .{ "minamiyamashiro.kyoto.jp", {} }, .{ "miyazu.kyoto.jp", {} }, .{ "muko.kyoto.jp", {} }, .{ "nagaokakyo.kyoto.jp", {} }, .{ "nakagyo.kyoto.jp", {} }, .{ "nantan.kyoto.jp", {} }, .{ "oyamazaki.kyoto.jp", {} }, .{ "sakyo.kyoto.jp", {} }, .{ "seika.kyoto.jp", {} }, .{ "tanabe.kyoto.jp", {} }, .{ "uji.kyoto.jp", {} }, .{ "ujitawara.kyoto.jp", {} }, .{ "wazuka.kyoto.jp", {} }, .{ "yamashina.kyoto.jp", {} }, .{ "yawata.kyoto.jp", {} }, .{ "asahi.mie.jp", {} }, .{ "inabe.mie.jp", {} }, .{ "ise.mie.jp", {} }, .{ "kameyama.mie.jp", {} }, .{ "kawagoe.mie.jp", {} }, .{ "kiho.mie.jp", {} }, .{ "kisosaki.mie.jp", {} }, .{ "kiwa.mie.jp", {} }, .{ "komono.mie.jp", {} }, .{ "kumano.mie.jp", {} }, .{ "kuwana.mie.jp", {} }, .{ "matsusaka.mie.jp", {} }, .{ "meiwa.mie.jp", {} }, .{ "mihama.mie.jp", {} }, .{ "minamiise.mie.jp", {} }, .{ "misugi.mie.jp", {} }, .{ "miyama.mie.jp", {} }, .{ "nabari.mie.jp", {} }, .{ "shima.mie.jp", {} }, .{ "suzuka.mie.jp", {} }, .{ "tado.mie.jp", {} }, .{ "taiki.mie.jp", {} }, .{ "taki.mie.jp", {} }, .{ "tamaki.mie.jp", {} }, .{ "toba.mie.jp", {} }, .{ "tsu.mie.jp", {} }, .{ "udono.mie.jp", {} }, .{ "ureshino.mie.jp", {} }, .{ "watarai.mie.jp", {} }, .{ "yokkaichi.mie.jp", {} }, .{ "furukawa.miyagi.jp", {} }, .{ "higashimatsushima.miyagi.jp", {} }, .{ "ishinomaki.miyagi.jp", {} }, .{ "iwanuma.miyagi.jp", {} }, .{ "kakuda.miyagi.jp", {} }, .{ "kami.miyagi.jp", {} }, .{ "kawasaki.miyagi.jp", {} }, .{ "marumori.miyagi.jp", {} }, .{ "matsushima.miyagi.jp", {} }, .{ "minamisanriku.miyagi.jp", {} }, .{ "misato.miyagi.jp", {} }, .{ "murata.miyagi.jp", {} }, .{ "natori.miyagi.jp", {} }, .{ "ogawara.miyagi.jp", {} }, .{ "ohira.miyagi.jp", {} }, .{ "onagawa.miyagi.jp", {} }, .{ "osaki.miyagi.jp", {} }, .{ "rifu.miyagi.jp", {} }, .{ "semine.miyagi.jp", {} }, .{ "shibata.miyagi.jp", {} }, .{ "shichikashuku.miyagi.jp", {} }, .{ "shikama.miyagi.jp", {} }, .{ "shiogama.miyagi.jp", {} }, .{ "shiroishi.miyagi.jp", {} }, .{ "tagajo.miyagi.jp", {} }, .{ "taiwa.miyagi.jp", {} }, .{ "tome.miyagi.jp", {} }, .{ "tomiya.miyagi.jp", {} }, .{ "wakuya.miyagi.jp", {} }, .{ "watari.miyagi.jp", {} }, .{ "yamamoto.miyagi.jp", {} }, .{ "zao.miyagi.jp", {} }, .{ "aya.miyazaki.jp", {} }, .{ "ebino.miyazaki.jp", {} }, .{ "gokase.miyazaki.jp", {} }, .{ "hyuga.miyazaki.jp", {} }, .{ "kadogawa.miyazaki.jp", {} }, .{ "kawaminami.miyazaki.jp", {} }, .{ "kijo.miyazaki.jp", {} }, .{ "kitagawa.miyazaki.jp", {} }, .{ "kitakata.miyazaki.jp", {} }, .{ "kitaura.miyazaki.jp", {} }, .{ "kobayashi.miyazaki.jp", {} }, .{ "kunitomi.miyazaki.jp", {} }, .{ "kushima.miyazaki.jp", {} }, .{ "mimata.miyazaki.jp", {} }, .{ "miyakonojo.miyazaki.jp", {} }, .{ "miyazaki.miyazaki.jp", {} }, .{ "morotsuka.miyazaki.jp", {} }, .{ "nichinan.miyazaki.jp", {} }, .{ "nishimera.miyazaki.jp", {} }, .{ "nobeoka.miyazaki.jp", {} }, .{ "saito.miyazaki.jp", {} }, .{ "shiiba.miyazaki.jp", {} }, .{ "shintomi.miyazaki.jp", {} }, .{ "takaharu.miyazaki.jp", {} }, .{ "takanabe.miyazaki.jp", {} }, .{ "takazaki.miyazaki.jp", {} }, .{ "tsuno.miyazaki.jp", {} }, .{ "achi.nagano.jp", {} }, .{ "agematsu.nagano.jp", {} }, .{ "anan.nagano.jp", {} }, .{ "aoki.nagano.jp", {} }, .{ "asahi.nagano.jp", {} }, .{ "azumino.nagano.jp", {} }, .{ "chikuhoku.nagano.jp", {} }, .{ "chikuma.nagano.jp", {} }, .{ "chino.nagano.jp", {} }, .{ "fujimi.nagano.jp", {} }, .{ "hakuba.nagano.jp", {} }, .{ "hara.nagano.jp", {} }, .{ "hiraya.nagano.jp", {} }, .{ "iida.nagano.jp", {} }, .{ "iijima.nagano.jp", {} }, .{ "iiyama.nagano.jp", {} }, .{ "iizuna.nagano.jp", {} }, .{ "ikeda.nagano.jp", {} }, .{ "ikusaka.nagano.jp", {} }, .{ "ina.nagano.jp", {} }, .{ "karuizawa.nagano.jp", {} }, .{ "kawakami.nagano.jp", {} }, .{ "kiso.nagano.jp", {} }, .{ "kisofukushima.nagano.jp", {} }, .{ "kitaaiki.nagano.jp", {} }, .{ "komagane.nagano.jp", {} }, .{ "komoro.nagano.jp", {} }, .{ "matsukawa.nagano.jp", {} }, .{ "matsumoto.nagano.jp", {} }, .{ "miasa.nagano.jp", {} }, .{ "minamiaiki.nagano.jp", {} }, .{ "minamimaki.nagano.jp", {} }, .{ "minamiminowa.nagano.jp", {} }, .{ "minowa.nagano.jp", {} }, .{ "miyada.nagano.jp", {} }, .{ "miyota.nagano.jp", {} }, .{ "mochizuki.nagano.jp", {} }, .{ "nagano.nagano.jp", {} }, .{ "nagawa.nagano.jp", {} }, .{ "nagiso.nagano.jp", {} }, .{ "nakagawa.nagano.jp", {} }, .{ "nakano.nagano.jp", {} }, .{ "nozawaonsen.nagano.jp", {} }, .{ "obuse.nagano.jp", {} }, .{ "ogawa.nagano.jp", {} }, .{ "okaya.nagano.jp", {} }, .{ "omachi.nagano.jp", {} }, .{ "omi.nagano.jp", {} }, .{ "ookuwa.nagano.jp", {} }, .{ "ooshika.nagano.jp", {} }, .{ "otaki.nagano.jp", {} }, .{ "otari.nagano.jp", {} }, .{ "sakae.nagano.jp", {} }, .{ "sakaki.nagano.jp", {} }, .{ "saku.nagano.jp", {} }, .{ "sakuho.nagano.jp", {} }, .{ "shimosuwa.nagano.jp", {} }, .{ "shinanomachi.nagano.jp", {} }, .{ "shiojiri.nagano.jp", {} }, .{ "suwa.nagano.jp", {} }, .{ "suzaka.nagano.jp", {} }, .{ "takagi.nagano.jp", {} }, .{ "takamori.nagano.jp", {} }, .{ "takayama.nagano.jp", {} }, .{ "tateshina.nagano.jp", {} }, .{ "tatsuno.nagano.jp", {} }, .{ "togakushi.nagano.jp", {} }, .{ "togura.nagano.jp", {} }, .{ "tomi.nagano.jp", {} }, .{ "ueda.nagano.jp", {} }, .{ "wada.nagano.jp", {} }, .{ "yamagata.nagano.jp", {} }, .{ "yamanouchi.nagano.jp", {} }, .{ "yasaka.nagano.jp", {} }, .{ "yasuoka.nagano.jp", {} }, .{ "chijiwa.nagasaki.jp", {} }, .{ "futsu.nagasaki.jp", {} }, .{ "goto.nagasaki.jp", {} }, .{ "hasami.nagasaki.jp", {} }, .{ "hirado.nagasaki.jp", {} }, .{ "iki.nagasaki.jp", {} }, .{ "isahaya.nagasaki.jp", {} }, .{ "kawatana.nagasaki.jp", {} }, .{ "kuchinotsu.nagasaki.jp", {} }, .{ "matsuura.nagasaki.jp", {} }, .{ "nagasaki.nagasaki.jp", {} }, .{ "obama.nagasaki.jp", {} }, .{ "omura.nagasaki.jp", {} }, .{ "oseto.nagasaki.jp", {} }, .{ "saikai.nagasaki.jp", {} }, .{ "sasebo.nagasaki.jp", {} }, .{ "seihi.nagasaki.jp", {} }, .{ "shimabara.nagasaki.jp", {} }, .{ "shinkamigoto.nagasaki.jp", {} }, .{ "togitsu.nagasaki.jp", {} }, .{ "tsushima.nagasaki.jp", {} }, .{ "unzen.nagasaki.jp", {} }, .{ "ando.nara.jp", {} }, .{ "gose.nara.jp", {} }, .{ "heguri.nara.jp", {} }, .{ "higashiyoshino.nara.jp", {} }, .{ "ikaruga.nara.jp", {} }, .{ "ikoma.nara.jp", {} }, .{ "kamikitayama.nara.jp", {} }, .{ "kanmaki.nara.jp", {} }, .{ "kashiba.nara.jp", {} }, .{ "kashihara.nara.jp", {} }, .{ "katsuragi.nara.jp", {} }, .{ "kawai.nara.jp", {} }, .{ "kawakami.nara.jp", {} }, .{ "kawanishi.nara.jp", {} }, .{ "koryo.nara.jp", {} }, .{ "kurotaki.nara.jp", {} }, .{ "mitsue.nara.jp", {} }, .{ "miyake.nara.jp", {} }, .{ "nara.nara.jp", {} }, .{ "nosegawa.nara.jp", {} }, .{ "oji.nara.jp", {} }, .{ "ouda.nara.jp", {} }, .{ "oyodo.nara.jp", {} }, .{ "sakurai.nara.jp", {} }, .{ "sango.nara.jp", {} }, .{ "shimoichi.nara.jp", {} }, .{ "shimokitayama.nara.jp", {} }, .{ "shinjo.nara.jp", {} }, .{ "soni.nara.jp", {} }, .{ "takatori.nara.jp", {} }, .{ "tawaramoto.nara.jp", {} }, .{ "tenkawa.nara.jp", {} }, .{ "tenri.nara.jp", {} }, .{ "uda.nara.jp", {} }, .{ "yamatokoriyama.nara.jp", {} }, .{ "yamatotakada.nara.jp", {} }, .{ "yamazoe.nara.jp", {} }, .{ "yoshino.nara.jp", {} }, .{ "aga.niigata.jp", {} }, .{ "agano.niigata.jp", {} }, .{ "gosen.niigata.jp", {} }, .{ "itoigawa.niigata.jp", {} }, .{ "izumozaki.niigata.jp", {} }, .{ "joetsu.niigata.jp", {} }, .{ "kamo.niigata.jp", {} }, .{ "kariwa.niigata.jp", {} }, .{ "kashiwazaki.niigata.jp", {} }, .{ "minamiuonuma.niigata.jp", {} }, .{ "mitsuke.niigata.jp", {} }, .{ "muika.niigata.jp", {} }, .{ "murakami.niigata.jp", {} }, .{ "myoko.niigata.jp", {} }, .{ "nagaoka.niigata.jp", {} }, .{ "niigata.niigata.jp", {} }, .{ "ojiya.niigata.jp", {} }, .{ "omi.niigata.jp", {} }, .{ "sado.niigata.jp", {} }, .{ "sanjo.niigata.jp", {} }, .{ "seiro.niigata.jp", {} }, .{ "seirou.niigata.jp", {} }, .{ "sekikawa.niigata.jp", {} }, .{ "shibata.niigata.jp", {} }, .{ "tagami.niigata.jp", {} }, .{ "tainai.niigata.jp", {} }, .{ "tochio.niigata.jp", {} }, .{ "tokamachi.niigata.jp", {} }, .{ "tsubame.niigata.jp", {} }, .{ "tsunan.niigata.jp", {} }, .{ "uonuma.niigata.jp", {} }, .{ "yahiko.niigata.jp", {} }, .{ "yoita.niigata.jp", {} }, .{ "yuzawa.niigata.jp", {} }, .{ "beppu.oita.jp", {} }, .{ "bungoono.oita.jp", {} }, .{ "bungotakada.oita.jp", {} }, .{ "hasama.oita.jp", {} }, .{ "hiji.oita.jp", {} }, .{ "himeshima.oita.jp", {} }, .{ "hita.oita.jp", {} }, .{ "kamitsue.oita.jp", {} }, .{ "kokonoe.oita.jp", {} }, .{ "kuju.oita.jp", {} }, .{ "kunisaki.oita.jp", {} }, .{ "kusu.oita.jp", {} }, .{ "oita.oita.jp", {} }, .{ "saiki.oita.jp", {} }, .{ "taketa.oita.jp", {} }, .{ "tsukumi.oita.jp", {} }, .{ "usa.oita.jp", {} }, .{ "usuki.oita.jp", {} }, .{ "yufu.oita.jp", {} }, .{ "akaiwa.okayama.jp", {} }, .{ "asakuchi.okayama.jp", {} }, .{ "bizen.okayama.jp", {} }, .{ "hayashima.okayama.jp", {} }, .{ "ibara.okayama.jp", {} }, .{ "kagamino.okayama.jp", {} }, .{ "kasaoka.okayama.jp", {} }, .{ "kibichuo.okayama.jp", {} }, .{ "kumenan.okayama.jp", {} }, .{ "kurashiki.okayama.jp", {} }, .{ "maniwa.okayama.jp", {} }, .{ "misaki.okayama.jp", {} }, .{ "nagi.okayama.jp", {} }, .{ "niimi.okayama.jp", {} }, .{ "nishiawakura.okayama.jp", {} }, .{ "okayama.okayama.jp", {} }, .{ "satosho.okayama.jp", {} }, .{ "setouchi.okayama.jp", {} }, .{ "shinjo.okayama.jp", {} }, .{ "shoo.okayama.jp", {} }, .{ "soja.okayama.jp", {} }, .{ "takahashi.okayama.jp", {} }, .{ "tamano.okayama.jp", {} }, .{ "tsuyama.okayama.jp", {} }, .{ "wake.okayama.jp", {} }, .{ "yakage.okayama.jp", {} }, .{ "aguni.okinawa.jp", {} }, .{ "ginowan.okinawa.jp", {} }, .{ "ginoza.okinawa.jp", {} }, .{ "gushikami.okinawa.jp", {} }, .{ "haebaru.okinawa.jp", {} }, .{ "higashi.okinawa.jp", {} }, .{ "hirara.okinawa.jp", {} }, .{ "iheya.okinawa.jp", {} }, .{ "ishigaki.okinawa.jp", {} }, .{ "ishikawa.okinawa.jp", {} }, .{ "itoman.okinawa.jp", {} }, .{ "izena.okinawa.jp", {} }, .{ "kadena.okinawa.jp", {} }, .{ "kin.okinawa.jp", {} }, .{ "kitadaito.okinawa.jp", {} }, .{ "kitanakagusuku.okinawa.jp", {} }, .{ "kumejima.okinawa.jp", {} }, .{ "kunigami.okinawa.jp", {} }, .{ "minamidaito.okinawa.jp", {} }, .{ "motobu.okinawa.jp", {} }, .{ "nago.okinawa.jp", {} }, .{ "naha.okinawa.jp", {} }, .{ "nakagusuku.okinawa.jp", {} }, .{ "nakijin.okinawa.jp", {} }, .{ "nanjo.okinawa.jp", {} }, .{ "nishihara.okinawa.jp", {} }, .{ "ogimi.okinawa.jp", {} }, .{ "okinawa.okinawa.jp", {} }, .{ "onna.okinawa.jp", {} }, .{ "shimoji.okinawa.jp", {} }, .{ "taketomi.okinawa.jp", {} }, .{ "tarama.okinawa.jp", {} }, .{ "tokashiki.okinawa.jp", {} }, .{ "tomigusuku.okinawa.jp", {} }, .{ "tonaki.okinawa.jp", {} }, .{ "urasoe.okinawa.jp", {} }, .{ "uruma.okinawa.jp", {} }, .{ "yaese.okinawa.jp", {} }, .{ "yomitan.okinawa.jp", {} }, .{ "yonabaru.okinawa.jp", {} }, .{ "yonaguni.okinawa.jp", {} }, .{ "zamami.okinawa.jp", {} }, .{ "abeno.osaka.jp", {} }, .{ "chihayaakasaka.osaka.jp", {} }, .{ "chuo.osaka.jp", {} }, .{ "daito.osaka.jp", {} }, .{ "fujiidera.osaka.jp", {} }, .{ "habikino.osaka.jp", {} }, .{ "hannan.osaka.jp", {} }, .{ "higashiosaka.osaka.jp", {} }, .{ "higashisumiyoshi.osaka.jp", {} }, .{ "higashiyodogawa.osaka.jp", {} }, .{ "hirakata.osaka.jp", {} }, .{ "ibaraki.osaka.jp", {} }, .{ "ikeda.osaka.jp", {} }, .{ "izumi.osaka.jp", {} }, .{ "izumiotsu.osaka.jp", {} }, .{ "izumisano.osaka.jp", {} }, .{ "kadoma.osaka.jp", {} }, .{ "kaizuka.osaka.jp", {} }, .{ "kanan.osaka.jp", {} }, .{ "kashiwara.osaka.jp", {} }, .{ "katano.osaka.jp", {} }, .{ "kawachinagano.osaka.jp", {} }, .{ "kishiwada.osaka.jp", {} }, .{ "kita.osaka.jp", {} }, .{ "kumatori.osaka.jp", {} }, .{ "matsubara.osaka.jp", {} }, .{ "minato.osaka.jp", {} }, .{ "minoh.osaka.jp", {} }, .{ "misaki.osaka.jp", {} }, .{ "moriguchi.osaka.jp", {} }, .{ "neyagawa.osaka.jp", {} }, .{ "nishi.osaka.jp", {} }, .{ "nose.osaka.jp", {} }, .{ "osakasayama.osaka.jp", {} }, .{ "sakai.osaka.jp", {} }, .{ "sayama.osaka.jp", {} }, .{ "sennan.osaka.jp", {} }, .{ "settsu.osaka.jp", {} }, .{ "shijonawate.osaka.jp", {} }, .{ "shimamoto.osaka.jp", {} }, .{ "suita.osaka.jp", {} }, .{ "tadaoka.osaka.jp", {} }, .{ "taishi.osaka.jp", {} }, .{ "tajiri.osaka.jp", {} }, .{ "takaishi.osaka.jp", {} }, .{ "takatsuki.osaka.jp", {} }, .{ "tondabayashi.osaka.jp", {} }, .{ "toyonaka.osaka.jp", {} }, .{ "toyono.osaka.jp", {} }, .{ "yao.osaka.jp", {} }, .{ "ariake.saga.jp", {} }, .{ "arita.saga.jp", {} }, .{ "fukudomi.saga.jp", {} }, .{ "genkai.saga.jp", {} }, .{ "hamatama.saga.jp", {} }, .{ "hizen.saga.jp", {} }, .{ "imari.saga.jp", {} }, .{ "kamimine.saga.jp", {} }, .{ "kanzaki.saga.jp", {} }, .{ "karatsu.saga.jp", {} }, .{ "kashima.saga.jp", {} }, .{ "kitagata.saga.jp", {} }, .{ "kitahata.saga.jp", {} }, .{ "kiyama.saga.jp", {} }, .{ "kouhoku.saga.jp", {} }, .{ "kyuragi.saga.jp", {} }, .{ "nishiarita.saga.jp", {} }, .{ "ogi.saga.jp", {} }, .{ "omachi.saga.jp", {} }, .{ "ouchi.saga.jp", {} }, .{ "saga.saga.jp", {} }, .{ "shiroishi.saga.jp", {} }, .{ "taku.saga.jp", {} }, .{ "tara.saga.jp", {} }, .{ "tosu.saga.jp", {} }, .{ "yoshinogari.saga.jp", {} }, .{ "arakawa.saitama.jp", {} }, .{ "asaka.saitama.jp", {} }, .{ "chichibu.saitama.jp", {} }, .{ "fujimi.saitama.jp", {} }, .{ "fujimino.saitama.jp", {} }, .{ "fukaya.saitama.jp", {} }, .{ "hanno.saitama.jp", {} }, .{ "hanyu.saitama.jp", {} }, .{ "hasuda.saitama.jp", {} }, .{ "hatogaya.saitama.jp", {} }, .{ "hatoyama.saitama.jp", {} }, .{ "hidaka.saitama.jp", {} }, .{ "higashichichibu.saitama.jp", {} }, .{ "higashimatsuyama.saitama.jp", {} }, .{ "honjo.saitama.jp", {} }, .{ "ina.saitama.jp", {} }, .{ "iruma.saitama.jp", {} }, .{ "iwatsuki.saitama.jp", {} }, .{ "kamiizumi.saitama.jp", {} }, .{ "kamikawa.saitama.jp", {} }, .{ "kamisato.saitama.jp", {} }, .{ "kasukabe.saitama.jp", {} }, .{ "kawagoe.saitama.jp", {} }, .{ "kawaguchi.saitama.jp", {} }, .{ "kawajima.saitama.jp", {} }, .{ "kazo.saitama.jp", {} }, .{ "kitamoto.saitama.jp", {} }, .{ "koshigaya.saitama.jp", {} }, .{ "kounosu.saitama.jp", {} }, .{ "kuki.saitama.jp", {} }, .{ "kumagaya.saitama.jp", {} }, .{ "matsubushi.saitama.jp", {} }, .{ "minano.saitama.jp", {} }, .{ "misato.saitama.jp", {} }, .{ "miyashiro.saitama.jp", {} }, .{ "miyoshi.saitama.jp", {} }, .{ "moroyama.saitama.jp", {} }, .{ "nagatoro.saitama.jp", {} }, .{ "namegawa.saitama.jp", {} }, .{ "niiza.saitama.jp", {} }, .{ "ogano.saitama.jp", {} }, .{ "ogawa.saitama.jp", {} }, .{ "ogose.saitama.jp", {} }, .{ "okegawa.saitama.jp", {} }, .{ "omiya.saitama.jp", {} }, .{ "otaki.saitama.jp", {} }, .{ "ranzan.saitama.jp", {} }, .{ "ryokami.saitama.jp", {} }, .{ "saitama.saitama.jp", {} }, .{ "sakado.saitama.jp", {} }, .{ "satte.saitama.jp", {} }, .{ "sayama.saitama.jp", {} }, .{ "shiki.saitama.jp", {} }, .{ "shiraoka.saitama.jp", {} }, .{ "soka.saitama.jp", {} }, .{ "sugito.saitama.jp", {} }, .{ "toda.saitama.jp", {} }, .{ "tokigawa.saitama.jp", {} }, .{ "tokorozawa.saitama.jp", {} }, .{ "tsurugashima.saitama.jp", {} }, .{ "urawa.saitama.jp", {} }, .{ "warabi.saitama.jp", {} }, .{ "yashio.saitama.jp", {} }, .{ "yokoze.saitama.jp", {} }, .{ "yono.saitama.jp", {} }, .{ "yorii.saitama.jp", {} }, .{ "yoshida.saitama.jp", {} }, .{ "yoshikawa.saitama.jp", {} }, .{ "yoshimi.saitama.jp", {} }, .{ "aisho.shiga.jp", {} }, .{ "gamo.shiga.jp", {} }, .{ "higashiomi.shiga.jp", {} }, .{ "hikone.shiga.jp", {} }, .{ "koka.shiga.jp", {} }, .{ "konan.shiga.jp", {} }, .{ "kosei.shiga.jp", {} }, .{ "koto.shiga.jp", {} }, .{ "kusatsu.shiga.jp", {} }, .{ "maibara.shiga.jp", {} }, .{ "moriyama.shiga.jp", {} }, .{ "nagahama.shiga.jp", {} }, .{ "nishiazai.shiga.jp", {} }, .{ "notogawa.shiga.jp", {} }, .{ "omihachiman.shiga.jp", {} }, .{ "otsu.shiga.jp", {} }, .{ "ritto.shiga.jp", {} }, .{ "ryuoh.shiga.jp", {} }, .{ "takashima.shiga.jp", {} }, .{ "takatsuki.shiga.jp", {} }, .{ "torahime.shiga.jp", {} }, .{ "toyosato.shiga.jp", {} }, .{ "yasu.shiga.jp", {} }, .{ "akagi.shimane.jp", {} }, .{ "ama.shimane.jp", {} }, .{ "gotsu.shimane.jp", {} }, .{ "hamada.shimane.jp", {} }, .{ "higashiizumo.shimane.jp", {} }, .{ "hikawa.shimane.jp", {} }, .{ "hikimi.shimane.jp", {} }, .{ "izumo.shimane.jp", {} }, .{ "kakinoki.shimane.jp", {} }, .{ "masuda.shimane.jp", {} }, .{ "matsue.shimane.jp", {} }, .{ "misato.shimane.jp", {} }, .{ "nishinoshima.shimane.jp", {} }, .{ "ohda.shimane.jp", {} }, .{ "okinoshima.shimane.jp", {} }, .{ "okuizumo.shimane.jp", {} }, .{ "shimane.shimane.jp", {} }, .{ "tamayu.shimane.jp", {} }, .{ "tsuwano.shimane.jp", {} }, .{ "unnan.shimane.jp", {} }, .{ "yakumo.shimane.jp", {} }, .{ "yasugi.shimane.jp", {} }, .{ "yatsuka.shimane.jp", {} }, .{ "arai.shizuoka.jp", {} }, .{ "atami.shizuoka.jp", {} }, .{ "fuji.shizuoka.jp", {} }, .{ "fujieda.shizuoka.jp", {} }, .{ "fujikawa.shizuoka.jp", {} }, .{ "fujinomiya.shizuoka.jp", {} }, .{ "fukuroi.shizuoka.jp", {} }, .{ "gotemba.shizuoka.jp", {} }, .{ "haibara.shizuoka.jp", {} }, .{ "hamamatsu.shizuoka.jp", {} }, .{ "higashiizu.shizuoka.jp", {} }, .{ "ito.shizuoka.jp", {} }, .{ "iwata.shizuoka.jp", {} }, .{ "izu.shizuoka.jp", {} }, .{ "izunokuni.shizuoka.jp", {} }, .{ "kakegawa.shizuoka.jp", {} }, .{ "kannami.shizuoka.jp", {} }, .{ "kawanehon.shizuoka.jp", {} }, .{ "kawazu.shizuoka.jp", {} }, .{ "kikugawa.shizuoka.jp", {} }, .{ "kosai.shizuoka.jp", {} }, .{ "makinohara.shizuoka.jp", {} }, .{ "matsuzaki.shizuoka.jp", {} }, .{ "minamiizu.shizuoka.jp", {} }, .{ "mishima.shizuoka.jp", {} }, .{ "morimachi.shizuoka.jp", {} }, .{ "nishiizu.shizuoka.jp", {} }, .{ "numazu.shizuoka.jp", {} }, .{ "omaezaki.shizuoka.jp", {} }, .{ "shimada.shizuoka.jp", {} }, .{ "shimizu.shizuoka.jp", {} }, .{ "shimoda.shizuoka.jp", {} }, .{ "shizuoka.shizuoka.jp", {} }, .{ "susono.shizuoka.jp", {} }, .{ "yaizu.shizuoka.jp", {} }, .{ "yoshida.shizuoka.jp", {} }, .{ "ashikaga.tochigi.jp", {} }, .{ "bato.tochigi.jp", {} }, .{ "haga.tochigi.jp", {} }, .{ "ichikai.tochigi.jp", {} }, .{ "iwafune.tochigi.jp", {} }, .{ "kaminokawa.tochigi.jp", {} }, .{ "kanuma.tochigi.jp", {} }, .{ "karasuyama.tochigi.jp", {} }, .{ "kuroiso.tochigi.jp", {} }, .{ "mashiko.tochigi.jp", {} }, .{ "mibu.tochigi.jp", {} }, .{ "moka.tochigi.jp", {} }, .{ "motegi.tochigi.jp", {} }, .{ "nasu.tochigi.jp", {} }, .{ "nasushiobara.tochigi.jp", {} }, .{ "nikko.tochigi.jp", {} }, .{ "nishikata.tochigi.jp", {} }, .{ "nogi.tochigi.jp", {} }, .{ "ohira.tochigi.jp", {} }, .{ "ohtawara.tochigi.jp", {} }, .{ "oyama.tochigi.jp", {} }, .{ "sakura.tochigi.jp", {} }, .{ "sano.tochigi.jp", {} }, .{ "shimotsuke.tochigi.jp", {} }, .{ "shioya.tochigi.jp", {} }, .{ "takanezawa.tochigi.jp", {} }, .{ "tochigi.tochigi.jp", {} }, .{ "tsuga.tochigi.jp", {} }, .{ "ujiie.tochigi.jp", {} }, .{ "utsunomiya.tochigi.jp", {} }, .{ "yaita.tochigi.jp", {} }, .{ "aizumi.tokushima.jp", {} }, .{ "anan.tokushima.jp", {} }, .{ "ichiba.tokushima.jp", {} }, .{ "itano.tokushima.jp", {} }, .{ "kainan.tokushima.jp", {} }, .{ "komatsushima.tokushima.jp", {} }, .{ "matsushige.tokushima.jp", {} }, .{ "mima.tokushima.jp", {} }, .{ "minami.tokushima.jp", {} }, .{ "miyoshi.tokushima.jp", {} }, .{ "mugi.tokushima.jp", {} }, .{ "nakagawa.tokushima.jp", {} }, .{ "naruto.tokushima.jp", {} }, .{ "sanagochi.tokushima.jp", {} }, .{ "shishikui.tokushima.jp", {} }, .{ "tokushima.tokushima.jp", {} }, .{ "wajiki.tokushima.jp", {} }, .{ "adachi.tokyo.jp", {} }, .{ "akiruno.tokyo.jp", {} }, .{ "akishima.tokyo.jp", {} }, .{ "aogashima.tokyo.jp", {} }, .{ "arakawa.tokyo.jp", {} }, .{ "bunkyo.tokyo.jp", {} }, .{ "chiyoda.tokyo.jp", {} }, .{ "chofu.tokyo.jp", {} }, .{ "chuo.tokyo.jp", {} }, .{ "edogawa.tokyo.jp", {} }, .{ "fuchu.tokyo.jp", {} }, .{ "fussa.tokyo.jp", {} }, .{ "hachijo.tokyo.jp", {} }, .{ "hachioji.tokyo.jp", {} }, .{ "hamura.tokyo.jp", {} }, .{ "higashikurume.tokyo.jp", {} }, .{ "higashimurayama.tokyo.jp", {} }, .{ "higashiyamato.tokyo.jp", {} }, .{ "hino.tokyo.jp", {} }, .{ "hinode.tokyo.jp", {} }, .{ "hinohara.tokyo.jp", {} }, .{ "inagi.tokyo.jp", {} }, .{ "itabashi.tokyo.jp", {} }, .{ "katsushika.tokyo.jp", {} }, .{ "kita.tokyo.jp", {} }, .{ "kiyose.tokyo.jp", {} }, .{ "kodaira.tokyo.jp", {} }, .{ "koganei.tokyo.jp", {} }, .{ "kokubunji.tokyo.jp", {} }, .{ "komae.tokyo.jp", {} }, .{ "koto.tokyo.jp", {} }, .{ "kouzushima.tokyo.jp", {} }, .{ "kunitachi.tokyo.jp", {} }, .{ "machida.tokyo.jp", {} }, .{ "meguro.tokyo.jp", {} }, .{ "minato.tokyo.jp", {} }, .{ "mitaka.tokyo.jp", {} }, .{ "mizuho.tokyo.jp", {} }, .{ "musashimurayama.tokyo.jp", {} }, .{ "musashino.tokyo.jp", {} }, .{ "nakano.tokyo.jp", {} }, .{ "nerima.tokyo.jp", {} }, .{ "ogasawara.tokyo.jp", {} }, .{ "okutama.tokyo.jp", {} }, .{ "ome.tokyo.jp", {} }, .{ "oshima.tokyo.jp", {} }, .{ "ota.tokyo.jp", {} }, .{ "setagaya.tokyo.jp", {} }, .{ "shibuya.tokyo.jp", {} }, .{ "shinagawa.tokyo.jp", {} }, .{ "shinjuku.tokyo.jp", {} }, .{ "suginami.tokyo.jp", {} }, .{ "sumida.tokyo.jp", {} }, .{ "tachikawa.tokyo.jp", {} }, .{ "taito.tokyo.jp", {} }, .{ "tama.tokyo.jp", {} }, .{ "toshima.tokyo.jp", {} }, .{ "chizu.tottori.jp", {} }, .{ "hino.tottori.jp", {} }, .{ "kawahara.tottori.jp", {} }, .{ "koge.tottori.jp", {} }, .{ "kotoura.tottori.jp", {} }, .{ "misasa.tottori.jp", {} }, .{ "nanbu.tottori.jp", {} }, .{ "nichinan.tottori.jp", {} }, .{ "sakaiminato.tottori.jp", {} }, .{ "tottori.tottori.jp", {} }, .{ "wakasa.tottori.jp", {} }, .{ "yazu.tottori.jp", {} }, .{ "yonago.tottori.jp", {} }, .{ "asahi.toyama.jp", {} }, .{ "fuchu.toyama.jp", {} }, .{ "fukumitsu.toyama.jp", {} }, .{ "funahashi.toyama.jp", {} }, .{ "himi.toyama.jp", {} }, .{ "imizu.toyama.jp", {} }, .{ "inami.toyama.jp", {} }, .{ "johana.toyama.jp", {} }, .{ "kamiichi.toyama.jp", {} }, .{ "kurobe.toyama.jp", {} }, .{ "nakaniikawa.toyama.jp", {} }, .{ "namerikawa.toyama.jp", {} }, .{ "nanto.toyama.jp", {} }, .{ "nyuzen.toyama.jp", {} }, .{ "oyabe.toyama.jp", {} }, .{ "taira.toyama.jp", {} }, .{ "takaoka.toyama.jp", {} }, .{ "tateyama.toyama.jp", {} }, .{ "toga.toyama.jp", {} }, .{ "tonami.toyama.jp", {} }, .{ "toyama.toyama.jp", {} }, .{ "unazuki.toyama.jp", {} }, .{ "uozu.toyama.jp", {} }, .{ "yamada.toyama.jp", {} }, .{ "arida.wakayama.jp", {} }, .{ "aridagawa.wakayama.jp", {} }, .{ "gobo.wakayama.jp", {} }, .{ "hashimoto.wakayama.jp", {} }, .{ "hidaka.wakayama.jp", {} }, .{ "hirogawa.wakayama.jp", {} }, .{ "inami.wakayama.jp", {} }, .{ "iwade.wakayama.jp", {} }, .{ "kainan.wakayama.jp", {} }, .{ "kamitonda.wakayama.jp", {} }, .{ "katsuragi.wakayama.jp", {} }, .{ "kimino.wakayama.jp", {} }, .{ "kinokawa.wakayama.jp", {} }, .{ "kitayama.wakayama.jp", {} }, .{ "koya.wakayama.jp", {} }, .{ "koza.wakayama.jp", {} }, .{ "kozagawa.wakayama.jp", {} }, .{ "kudoyama.wakayama.jp", {} }, .{ "kushimoto.wakayama.jp", {} }, .{ "mihama.wakayama.jp", {} }, .{ "misato.wakayama.jp", {} }, .{ "nachikatsuura.wakayama.jp", {} }, .{ "shingu.wakayama.jp", {} }, .{ "shirahama.wakayama.jp", {} }, .{ "taiji.wakayama.jp", {} }, .{ "tanabe.wakayama.jp", {} }, .{ "wakayama.wakayama.jp", {} }, .{ "yuasa.wakayama.jp", {} }, .{ "yura.wakayama.jp", {} }, .{ "asahi.yamagata.jp", {} }, .{ "funagata.yamagata.jp", {} }, .{ "higashine.yamagata.jp", {} }, .{ "iide.yamagata.jp", {} }, .{ "kahoku.yamagata.jp", {} }, .{ "kaminoyama.yamagata.jp", {} }, .{ "kaneyama.yamagata.jp", {} }, .{ "kawanishi.yamagata.jp", {} }, .{ "mamurogawa.yamagata.jp", {} }, .{ "mikawa.yamagata.jp", {} }, .{ "murayama.yamagata.jp", {} }, .{ "nagai.yamagata.jp", {} }, .{ "nakayama.yamagata.jp", {} }, .{ "nanyo.yamagata.jp", {} }, .{ "nishikawa.yamagata.jp", {} }, .{ "obanazawa.yamagata.jp", {} }, .{ "oe.yamagata.jp", {} }, .{ "oguni.yamagata.jp", {} }, .{ "ohkura.yamagata.jp", {} }, .{ "oishida.yamagata.jp", {} }, .{ "sagae.yamagata.jp", {} }, .{ "sakata.yamagata.jp", {} }, .{ "sakegawa.yamagata.jp", {} }, .{ "shinjo.yamagata.jp", {} }, .{ "shirataka.yamagata.jp", {} }, .{ "shonai.yamagata.jp", {} }, .{ "takahata.yamagata.jp", {} }, .{ "tendo.yamagata.jp", {} }, .{ "tozawa.yamagata.jp", {} }, .{ "tsuruoka.yamagata.jp", {} }, .{ "yamagata.yamagata.jp", {} }, .{ "yamanobe.yamagata.jp", {} }, .{ "yonezawa.yamagata.jp", {} }, .{ "yuza.yamagata.jp", {} }, .{ "abu.yamaguchi.jp", {} }, .{ "hagi.yamaguchi.jp", {} }, .{ "hikari.yamaguchi.jp", {} }, .{ "hofu.yamaguchi.jp", {} }, .{ "iwakuni.yamaguchi.jp", {} }, .{ "kudamatsu.yamaguchi.jp", {} }, .{ "mitou.yamaguchi.jp", {} }, .{ "nagato.yamaguchi.jp", {} }, .{ "oshima.yamaguchi.jp", {} }, .{ "shimonoseki.yamaguchi.jp", {} }, .{ "shunan.yamaguchi.jp", {} }, .{ "tabuse.yamaguchi.jp", {} }, .{ "tokuyama.yamaguchi.jp", {} }, .{ "toyota.yamaguchi.jp", {} }, .{ "ube.yamaguchi.jp", {} }, .{ "yuu.yamaguchi.jp", {} }, .{ "chuo.yamanashi.jp", {} }, .{ "doshi.yamanashi.jp", {} }, .{ "fuefuki.yamanashi.jp", {} }, .{ "fujikawa.yamanashi.jp", {} }, .{ "fujikawaguchiko.yamanashi.jp", {} }, .{ "fujiyoshida.yamanashi.jp", {} }, .{ "hayakawa.yamanashi.jp", {} }, .{ "hokuto.yamanashi.jp", {} }, .{ "ichikawamisato.yamanashi.jp", {} }, .{ "kai.yamanashi.jp", {} }, .{ "kofu.yamanashi.jp", {} }, .{ "koshu.yamanashi.jp", {} }, .{ "kosuge.yamanashi.jp", {} }, .{ "minami-alps.yamanashi.jp", {} }, .{ "minobu.yamanashi.jp", {} }, .{ "nakamichi.yamanashi.jp", {} }, .{ "nanbu.yamanashi.jp", {} }, .{ "narusawa.yamanashi.jp", {} }, .{ "nirasaki.yamanashi.jp", {} }, .{ "nishikatsura.yamanashi.jp", {} }, .{ "oshino.yamanashi.jp", {} }, .{ "otsuki.yamanashi.jp", {} }, .{ "showa.yamanashi.jp", {} }, .{ "tabayama.yamanashi.jp", {} }, .{ "tsuru.yamanashi.jp", {} }, .{ "uenohara.yamanashi.jp", {} }, .{ "yamanakako.yamanashi.jp", {} }, .{ "yamanashi.yamanashi.jp", {} }, .{ "ke", {} }, .{ "ac.ke", {} }, .{ "co.ke", {} }, .{ "go.ke", {} }, .{ "info.ke", {} }, .{ "me.ke", {} }, .{ "mobi.ke", {} }, .{ "ne.ke", {} }, .{ "or.ke", {} }, .{ "sc.ke", {} }, .{ "kg", {} }, .{ "com.kg", {} }, .{ "edu.kg", {} }, .{ "gov.kg", {} }, .{ "mil.kg", {} }, .{ "net.kg", {} }, .{ "org.kg", {} }, .{ "*.kh", {} }, .{ "ki", {} }, .{ "biz.ki", {} }, .{ "com.ki", {} }, .{ "edu.ki", {} }, .{ "gov.ki", {} }, .{ "info.ki", {} }, .{ "net.ki", {} }, .{ "org.ki", {} }, .{ "km", {} }, .{ "ass.km", {} }, .{ "com.km", {} }, .{ "edu.km", {} }, .{ "gov.km", {} }, .{ "mil.km", {} }, .{ "nom.km", {} }, .{ "org.km", {} }, .{ "prd.km", {} }, .{ "tm.km", {} }, .{ "asso.km", {} }, .{ "coop.km", {} }, .{ "gouv.km", {} }, .{ "medecin.km", {} }, .{ "notaires.km", {} }, .{ "pharmaciens.km", {} }, .{ "presse.km", {} }, .{ "veterinaire.km", {} }, .{ "kn", {} }, .{ "edu.kn", {} }, .{ "gov.kn", {} }, .{ "net.kn", {} }, .{ "org.kn", {} }, .{ "kp", {} }, .{ "com.kp", {} }, .{ "edu.kp", {} }, .{ "gov.kp", {} }, .{ "org.kp", {} }, .{ "rep.kp", {} }, .{ "tra.kp", {} }, .{ "kr", {} }, .{ "ac.kr", {} }, .{ "ai.kr", {} }, .{ "co.kr", {} }, .{ "es.kr", {} }, .{ "go.kr", {} }, .{ "hs.kr", {} }, .{ "io.kr", {} }, .{ "it.kr", {} }, .{ "kg.kr", {} }, .{ "me.kr", {} }, .{ "mil.kr", {} }, .{ "ms.kr", {} }, .{ "ne.kr", {} }, .{ "or.kr", {} }, .{ "pe.kr", {} }, .{ "re.kr", {} }, .{ "sc.kr", {} }, .{ "busan.kr", {} }, .{ "chungbuk.kr", {} }, .{ "chungnam.kr", {} }, .{ "daegu.kr", {} }, .{ "daejeon.kr", {} }, .{ "gangwon.kr", {} }, .{ "gwangju.kr", {} }, .{ "gyeongbuk.kr", {} }, .{ "gyeonggi.kr", {} }, .{ "gyeongnam.kr", {} }, .{ "incheon.kr", {} }, .{ "jeju.kr", {} }, .{ "jeonbuk.kr", {} }, .{ "jeonnam.kr", {} }, .{ "seoul.kr", {} }, .{ "ulsan.kr", {} }, .{ "kw", {} }, .{ "com.kw", {} }, .{ "edu.kw", {} }, .{ "emb.kw", {} }, .{ "gov.kw", {} }, .{ "ind.kw", {} }, .{ "net.kw", {} }, .{ "org.kw", {} }, .{ "ky", {} }, .{ "com.ky", {} }, .{ "edu.ky", {} }, .{ "net.ky", {} }, .{ "org.ky", {} }, .{ "kz", {} }, .{ "com.kz", {} }, .{ "edu.kz", {} }, .{ "gov.kz", {} }, .{ "mil.kz", {} }, .{ "net.kz", {} }, .{ "org.kz", {} }, .{ "la", {} }, .{ "com.la", {} }, .{ "edu.la", {} }, .{ "gov.la", {} }, .{ "info.la", {} }, .{ "int.la", {} }, .{ "net.la", {} }, .{ "org.la", {} }, .{ "per.la", {} }, .{ "lb", {} }, .{ "com.lb", {} }, .{ "edu.lb", {} }, .{ "gov.lb", {} }, .{ "net.lb", {} }, .{ "org.lb", {} }, .{ "lc", {} }, .{ "co.lc", {} }, .{ "com.lc", {} }, .{ "edu.lc", {} }, .{ "gov.lc", {} }, .{ "net.lc", {} }, .{ "org.lc", {} }, .{ "li", {} }, .{ "lk", {} }, .{ "ac.lk", {} }, .{ "assn.lk", {} }, .{ "com.lk", {} }, .{ "edu.lk", {} }, .{ "gov.lk", {} }, .{ "grp.lk", {} }, .{ "hotel.lk", {} }, .{ "int.lk", {} }, .{ "ltd.lk", {} }, .{ "net.lk", {} }, .{ "ngo.lk", {} }, .{ "org.lk", {} }, .{ "sch.lk", {} }, .{ "soc.lk", {} }, .{ "web.lk", {} }, .{ "lr", {} }, .{ "com.lr", {} }, .{ "edu.lr", {} }, .{ "gov.lr", {} }, .{ "net.lr", {} }, .{ "org.lr", {} }, .{ "ls", {} }, .{ "ac.ls", {} }, .{ "biz.ls", {} }, .{ "co.ls", {} }, .{ "edu.ls", {} }, .{ "gov.ls", {} }, .{ "info.ls", {} }, .{ "net.ls", {} }, .{ "org.ls", {} }, .{ "sc.ls", {} }, .{ "lt", {} }, .{ "gov.lt", {} }, .{ "lu", {} }, .{ "lv", {} }, .{ "asn.lv", {} }, .{ "com.lv", {} }, .{ "conf.lv", {} }, .{ "edu.lv", {} }, .{ "gov.lv", {} }, .{ "id.lv", {} }, .{ "mil.lv", {} }, .{ "net.lv", {} }, .{ "org.lv", {} }, .{ "ly", {} }, .{ "com.ly", {} }, .{ "edu.ly", {} }, .{ "gov.ly", {} }, .{ "id.ly", {} }, .{ "med.ly", {} }, .{ "net.ly", {} }, .{ "org.ly", {} }, .{ "plc.ly", {} }, .{ "sch.ly", {} }, .{ "ma", {} }, .{ "ac.ma", {} }, .{ "co.ma", {} }, .{ "gov.ma", {} }, .{ "net.ma", {} }, .{ "org.ma", {} }, .{ "press.ma", {} }, .{ "mc", {} }, .{ "asso.mc", {} }, .{ "tm.mc", {} }, .{ "md", {} }, .{ "me", {} }, .{ "ac.me", {} }, .{ "co.me", {} }, .{ "edu.me", {} }, .{ "gov.me", {} }, .{ "its.me", {} }, .{ "net.me", {} }, .{ "org.me", {} }, .{ "priv.me", {} }, .{ "mg", {} }, .{ "co.mg", {} }, .{ "com.mg", {} }, .{ "edu.mg", {} }, .{ "gov.mg", {} }, .{ "mil.mg", {} }, .{ "nom.mg", {} }, .{ "org.mg", {} }, .{ "prd.mg", {} }, .{ "mh", {} }, .{ "mil", {} }, .{ "mk", {} }, .{ "com.mk", {} }, .{ "edu.mk", {} }, .{ "gov.mk", {} }, .{ "inf.mk", {} }, .{ "name.mk", {} }, .{ "net.mk", {} }, .{ "org.mk", {} }, .{ "ml", {} }, .{ "ac.ml", {} }, .{ "art.ml", {} }, .{ "asso.ml", {} }, .{ "com.ml", {} }, .{ "edu.ml", {} }, .{ "gouv.ml", {} }, .{ "gov.ml", {} }, .{ "info.ml", {} }, .{ "inst.ml", {} }, .{ "net.ml", {} }, .{ "org.ml", {} }, .{ "pr.ml", {} }, .{ "presse.ml", {} }, .{ "*.mm", {} }, .{ "mn", {} }, .{ "edu.mn", {} }, .{ "gov.mn", {} }, .{ "org.mn", {} }, .{ "mo", {} }, .{ "com.mo", {} }, .{ "edu.mo", {} }, .{ "gov.mo", {} }, .{ "net.mo", {} }, .{ "org.mo", {} }, .{ "mobi", {} }, .{ "mp", {} }, .{ "mq", {} }, .{ "mr", {} }, .{ "gov.mr", {} }, .{ "ms", {} }, .{ "com.ms", {} }, .{ "edu.ms", {} }, .{ "gov.ms", {} }, .{ "net.ms", {} }, .{ "org.ms", {} }, .{ "mt", {} }, .{ "com.mt", {} }, .{ "edu.mt", {} }, .{ "net.mt", {} }, .{ "org.mt", {} }, .{ "mu", {} }, .{ "ac.mu", {} }, .{ "co.mu", {} }, .{ "com.mu", {} }, .{ "gov.mu", {} }, .{ "net.mu", {} }, .{ "or.mu", {} }, .{ "org.mu", {} }, .{ "museum", {} }, .{ "mv", {} }, .{ "aero.mv", {} }, .{ "biz.mv", {} }, .{ "com.mv", {} }, .{ "coop.mv", {} }, .{ "edu.mv", {} }, .{ "gov.mv", {} }, .{ "info.mv", {} }, .{ "int.mv", {} }, .{ "mil.mv", {} }, .{ "museum.mv", {} }, .{ "name.mv", {} }, .{ "net.mv", {} }, .{ "org.mv", {} }, .{ "pro.mv", {} }, .{ "mw", {} }, .{ "ac.mw", {} }, .{ "biz.mw", {} }, .{ "co.mw", {} }, .{ "com.mw", {} }, .{ "coop.mw", {} }, .{ "edu.mw", {} }, .{ "gov.mw", {} }, .{ "int.mw", {} }, .{ "net.mw", {} }, .{ "org.mw", {} }, .{ "mx", {} }, .{ "com.mx", {} }, .{ "edu.mx", {} }, .{ "gob.mx", {} }, .{ "net.mx", {} }, .{ "org.mx", {} }, .{ "my", {} }, .{ "biz.my", {} }, .{ "com.my", {} }, .{ "edu.my", {} }, .{ "gov.my", {} }, .{ "mil.my", {} }, .{ "name.my", {} }, .{ "net.my", {} }, .{ "org.my", {} }, .{ "mz", {} }, .{ "ac.mz", {} }, .{ "adv.mz", {} }, .{ "co.mz", {} }, .{ "edu.mz", {} }, .{ "gov.mz", {} }, .{ "mil.mz", {} }, .{ "net.mz", {} }, .{ "org.mz", {} }, .{ "na", {} }, .{ "alt.na", {} }, .{ "co.na", {} }, .{ "com.na", {} }, .{ "gov.na", {} }, .{ "net.na", {} }, .{ "org.na", {} }, .{ "name", {} }, .{ "nc", {} }, .{ "asso.nc", {} }, .{ "nom.nc", {} }, .{ "ne", {} }, .{ "net", {} }, .{ "nf", {} }, .{ "arts.nf", {} }, .{ "com.nf", {} }, .{ "firm.nf", {} }, .{ "info.nf", {} }, .{ "net.nf", {} }, .{ "other.nf", {} }, .{ "per.nf", {} }, .{ "rec.nf", {} }, .{ "store.nf", {} }, .{ "web.nf", {} }, .{ "ng", {} }, .{ "com.ng", {} }, .{ "edu.ng", {} }, .{ "gov.ng", {} }, .{ "i.ng", {} }, .{ "mil.ng", {} }, .{ "mobi.ng", {} }, .{ "name.ng", {} }, .{ "net.ng", {} }, .{ "org.ng", {} }, .{ "sch.ng", {} }, .{ "ni", {} }, .{ "ac.ni", {} }, .{ "biz.ni", {} }, .{ "co.ni", {} }, .{ "com.ni", {} }, .{ "edu.ni", {} }, .{ "gob.ni", {} }, .{ "in.ni", {} }, .{ "info.ni", {} }, .{ "int.ni", {} }, .{ "mil.ni", {} }, .{ "net.ni", {} }, .{ "nom.ni", {} }, .{ "org.ni", {} }, .{ "web.ni", {} }, .{ "nl", {} }, .{ "no", {} }, .{ "fhs.no", {} }, .{ "folkebibl.no", {} }, .{ "fylkesbibl.no", {} }, .{ "idrett.no", {} }, .{ "museum.no", {} }, .{ "priv.no", {} }, .{ "vgs.no", {} }, .{ "dep.no", {} }, .{ "herad.no", {} }, .{ "kommune.no", {} }, .{ "mil.no", {} }, .{ "stat.no", {} }, .{ "aa.no", {} }, .{ "ah.no", {} }, .{ "bu.no", {} }, .{ "fm.no", {} }, .{ "hl.no", {} }, .{ "hm.no", {} }, .{ "jan-mayen.no", {} }, .{ "mr.no", {} }, .{ "nl.no", {} }, .{ "nt.no", {} }, .{ "of.no", {} }, .{ "ol.no", {} }, .{ "oslo.no", {} }, .{ "rl.no", {} }, .{ "sf.no", {} }, .{ "st.no", {} }, .{ "svalbard.no", {} }, .{ "tm.no", {} }, .{ "tr.no", {} }, .{ "va.no", {} }, .{ "vf.no", {} }, .{ "gs.aa.no", {} }, .{ "gs.ah.no", {} }, .{ "gs.bu.no", {} }, .{ "gs.fm.no", {} }, .{ "gs.hl.no", {} }, .{ "gs.hm.no", {} }, .{ "gs.jan-mayen.no", {} }, .{ "gs.mr.no", {} }, .{ "gs.nl.no", {} }, .{ "gs.nt.no", {} }, .{ "gs.of.no", {} }, .{ "gs.ol.no", {} }, .{ "gs.oslo.no", {} }, .{ "gs.rl.no", {} }, .{ "gs.sf.no", {} }, .{ "gs.st.no", {} }, .{ "gs.svalbard.no", {} }, .{ "gs.tm.no", {} }, .{ "gs.tr.no", {} }, .{ "gs.va.no", {} }, .{ "gs.vf.no", {} }, .{ "akrehamn.no", {} }, .{ "åkrehamn.no", {} }, .{ "algard.no", {} }, .{ "ålgård.no", {} }, .{ "arna.no", {} }, .{ "bronnoysund.no", {} }, .{ "brønnøysund.no", {} }, .{ "brumunddal.no", {} }, .{ "bryne.no", {} }, .{ "drobak.no", {} }, .{ "drøbak.no", {} }, .{ "egersund.no", {} }, .{ "fetsund.no", {} }, .{ "floro.no", {} }, .{ "florø.no", {} }, .{ "fredrikstad.no", {} }, .{ "hokksund.no", {} }, .{ "honefoss.no", {} }, .{ "hønefoss.no", {} }, .{ "jessheim.no", {} }, .{ "jorpeland.no", {} }, .{ "jørpeland.no", {} }, .{ "kirkenes.no", {} }, .{ "kopervik.no", {} }, .{ "krokstadelva.no", {} }, .{ "langevag.no", {} }, .{ "langevåg.no", {} }, .{ "leirvik.no", {} }, .{ "mjondalen.no", {} }, .{ "mjøndalen.no", {} }, .{ "mo-i-rana.no", {} }, .{ "mosjoen.no", {} }, .{ "mosjøen.no", {} }, .{ "nesoddtangen.no", {} }, .{ "orkanger.no", {} }, .{ "osoyro.no", {} }, .{ "osøyro.no", {} }, .{ "raholt.no", {} }, .{ "råholt.no", {} }, .{ "sandnessjoen.no", {} }, .{ "sandnessjøen.no", {} }, .{ "skedsmokorset.no", {} }, .{ "slattum.no", {} }, .{ "spjelkavik.no", {} }, .{ "stathelle.no", {} }, .{ "stavern.no", {} }, .{ "stjordalshalsen.no", {} }, .{ "stjørdalshalsen.no", {} }, .{ "tananger.no", {} }, .{ "tranby.no", {} }, .{ "vossevangen.no", {} }, .{ "aarborte.no", {} }, .{ "aejrie.no", {} }, .{ "afjord.no", {} }, .{ "åfjord.no", {} }, .{ "agdenes.no", {} }, .{ "nes.akershus.no", {} }, .{ "aknoluokta.no", {} }, .{ "ákŋoluokta.no", {} }, .{ "al.no", {} }, .{ "ål.no", {} }, .{ "alaheadju.no", {} }, .{ "álaheadju.no", {} }, .{ "alesund.no", {} }, .{ "ålesund.no", {} }, .{ "alstahaug.no", {} }, .{ "alta.no", {} }, .{ "áltá.no", {} }, .{ "alvdal.no", {} }, .{ "amli.no", {} }, .{ "åmli.no", {} }, .{ "amot.no", {} }, .{ "åmot.no", {} }, .{ "andasuolo.no", {} }, .{ "andebu.no", {} }, .{ "andoy.no", {} }, .{ "andøy.no", {} }, .{ "ardal.no", {} }, .{ "årdal.no", {} }, .{ "aremark.no", {} }, .{ "arendal.no", {} }, .{ "ås.no", {} }, .{ "aseral.no", {} }, .{ "åseral.no", {} }, .{ "asker.no", {} }, .{ "askim.no", {} }, .{ "askoy.no", {} }, .{ "askøy.no", {} }, .{ "askvoll.no", {} }, .{ "asnes.no", {} }, .{ "åsnes.no", {} }, .{ "audnedaln.no", {} }, .{ "aukra.no", {} }, .{ "aure.no", {} }, .{ "aurland.no", {} }, .{ "aurskog-holand.no", {} }, .{ "aurskog-høland.no", {} }, .{ "austevoll.no", {} }, .{ "austrheim.no", {} }, .{ "averoy.no", {} }, .{ "averøy.no", {} }, .{ "badaddja.no", {} }, .{ "bådåddjå.no", {} }, .{ "bærum.no", {} }, .{ "bahcavuotna.no", {} }, .{ "báhcavuotna.no", {} }, .{ "bahccavuotna.no", {} }, .{ "báhccavuotna.no", {} }, .{ "baidar.no", {} }, .{ "báidár.no", {} }, .{ "bajddar.no", {} }, .{ "bájddar.no", {} }, .{ "balat.no", {} }, .{ "bálát.no", {} }, .{ "balestrand.no", {} }, .{ "ballangen.no", {} }, .{ "balsfjord.no", {} }, .{ "bamble.no", {} }, .{ "bardu.no", {} }, .{ "barum.no", {} }, .{ "batsfjord.no", {} }, .{ "båtsfjord.no", {} }, .{ "bearalvahki.no", {} }, .{ "bearalváhki.no", {} }, .{ "beardu.no", {} }, .{ "beiarn.no", {} }, .{ "berg.no", {} }, .{ "bergen.no", {} }, .{ "berlevag.no", {} }, .{ "berlevåg.no", {} }, .{ "bievat.no", {} }, .{ "bievát.no", {} }, .{ "bindal.no", {} }, .{ "birkenes.no", {} }, .{ "bjerkreim.no", {} }, .{ "bjugn.no", {} }, .{ "bodo.no", {} }, .{ "bodø.no", {} }, .{ "bokn.no", {} }, .{ "bomlo.no", {} }, .{ "bømlo.no", {} }, .{ "bremanger.no", {} }, .{ "bronnoy.no", {} }, .{ "brønnøy.no", {} }, .{ "budejju.no", {} }, .{ "nes.buskerud.no", {} }, .{ "bygland.no", {} }, .{ "bykle.no", {} }, .{ "cahcesuolo.no", {} }, .{ "čáhcesuolo.no", {} }, .{ "davvenjarga.no", {} }, .{ "davvenjárga.no", {} }, .{ "davvesiida.no", {} }, .{ "deatnu.no", {} }, .{ "dielddanuorri.no", {} }, .{ "divtasvuodna.no", {} }, .{ "divttasvuotna.no", {} }, .{ "donna.no", {} }, .{ "dønna.no", {} }, .{ "dovre.no", {} }, .{ "drammen.no", {} }, .{ "drangedal.no", {} }, .{ "dyroy.no", {} }, .{ "dyrøy.no", {} }, .{ "eid.no", {} }, .{ "eidfjord.no", {} }, .{ "eidsberg.no", {} }, .{ "eidskog.no", {} }, .{ "eidsvoll.no", {} }, .{ "eigersund.no", {} }, .{ "elverum.no", {} }, .{ "enebakk.no", {} }, .{ "engerdal.no", {} }, .{ "etne.no", {} }, .{ "etnedal.no", {} }, .{ "evenassi.no", {} }, .{ "evenášši.no", {} }, .{ "evenes.no", {} }, .{ "evje-og-hornnes.no", {} }, .{ "farsund.no", {} }, .{ "fauske.no", {} }, .{ "fedje.no", {} }, .{ "fet.no", {} }, .{ "finnoy.no", {} }, .{ "finnøy.no", {} }, .{ "fitjar.no", {} }, .{ "fjaler.no", {} }, .{ "fjell.no", {} }, .{ "fla.no", {} }, .{ "flå.no", {} }, .{ "flakstad.no", {} }, .{ "flatanger.no", {} }, .{ "flekkefjord.no", {} }, .{ "flesberg.no", {} }, .{ "flora.no", {} }, .{ "folldal.no", {} }, .{ "forde.no", {} }, .{ "førde.no", {} }, .{ "forsand.no", {} }, .{ "fosnes.no", {} }, .{ "fræna.no", {} }, .{ "frana.no", {} }, .{ "frei.no", {} }, .{ "frogn.no", {} }, .{ "froland.no", {} }, .{ "frosta.no", {} }, .{ "froya.no", {} }, .{ "frøya.no", {} }, .{ "fuoisku.no", {} }, .{ "fuossko.no", {} }, .{ "fusa.no", {} }, .{ "fyresdal.no", {} }, .{ "gaivuotna.no", {} }, .{ "gáivuotna.no", {} }, .{ "galsa.no", {} }, .{ "gálsá.no", {} }, .{ "gamvik.no", {} }, .{ "gangaviika.no", {} }, .{ "gáŋgaviika.no", {} }, .{ "gaular.no", {} }, .{ "gausdal.no", {} }, .{ "giehtavuoatna.no", {} }, .{ "gildeskal.no", {} }, .{ "gildeskål.no", {} }, .{ "giske.no", {} }, .{ "gjemnes.no", {} }, .{ "gjerdrum.no", {} }, .{ "gjerstad.no", {} }, .{ "gjesdal.no", {} }, .{ "gjovik.no", {} }, .{ "gjøvik.no", {} }, .{ "gloppen.no", {} }, .{ "gol.no", {} }, .{ "gran.no", {} }, .{ "grane.no", {} }, .{ "granvin.no", {} }, .{ "gratangen.no", {} }, .{ "grimstad.no", {} }, .{ "grong.no", {} }, .{ "grue.no", {} }, .{ "gulen.no", {} }, .{ "guovdageaidnu.no", {} }, .{ "ha.no", {} }, .{ "hå.no", {} }, .{ "habmer.no", {} }, .{ "hábmer.no", {} }, .{ "hadsel.no", {} }, .{ "hægebostad.no", {} }, .{ "hagebostad.no", {} }, .{ "halden.no", {} }, .{ "halsa.no", {} }, .{ "hamar.no", {} }, .{ "hamaroy.no", {} }, .{ "hammarfeasta.no", {} }, .{ "hámmárfeasta.no", {} }, .{ "hammerfest.no", {} }, .{ "hapmir.no", {} }, .{ "hápmir.no", {} }, .{ "haram.no", {} }, .{ "hareid.no", {} }, .{ "harstad.no", {} }, .{ "hasvik.no", {} }, .{ "hattfjelldal.no", {} }, .{ "haugesund.no", {} }, .{ "os.hedmark.no", {} }, .{ "valer.hedmark.no", {} }, .{ "våler.hedmark.no", {} }, .{ "hemne.no", {} }, .{ "hemnes.no", {} }, .{ "hemsedal.no", {} }, .{ "hitra.no", {} }, .{ "hjartdal.no", {} }, .{ "hjelmeland.no", {} }, .{ "hobol.no", {} }, .{ "hobøl.no", {} }, .{ "hof.no", {} }, .{ "hol.no", {} }, .{ "hole.no", {} }, .{ "holmestrand.no", {} }, .{ "holtalen.no", {} }, .{ "holtålen.no", {} }, .{ "os.hordaland.no", {} }, .{ "hornindal.no", {} }, .{ "horten.no", {} }, .{ "hoyanger.no", {} }, .{ "høyanger.no", {} }, .{ "hoylandet.no", {} }, .{ "høylandet.no", {} }, .{ "hurdal.no", {} }, .{ "hurum.no", {} }, .{ "hvaler.no", {} }, .{ "hyllestad.no", {} }, .{ "ibestad.no", {} }, .{ "inderoy.no", {} }, .{ "inderøy.no", {} }, .{ "iveland.no", {} }, .{ "ivgu.no", {} }, .{ "jevnaker.no", {} }, .{ "jolster.no", {} }, .{ "jølster.no", {} }, .{ "jondal.no", {} }, .{ "kafjord.no", {} }, .{ "kåfjord.no", {} }, .{ "karasjohka.no", {} }, .{ "kárášjohka.no", {} }, .{ "karasjok.no", {} }, .{ "karlsoy.no", {} }, .{ "karmoy.no", {} }, .{ "karmøy.no", {} }, .{ "kautokeino.no", {} }, .{ "klabu.no", {} }, .{ "klæbu.no", {} }, .{ "klepp.no", {} }, .{ "kongsberg.no", {} }, .{ "kongsvinger.no", {} }, .{ "kraanghke.no", {} }, .{ "kråanghke.no", {} }, .{ "kragero.no", {} }, .{ "kragerø.no", {} }, .{ "kristiansand.no", {} }, .{ "kristiansund.no", {} }, .{ "krodsherad.no", {} }, .{ "krødsherad.no", {} }, .{ "kvæfjord.no", {} }, .{ "kvænangen.no", {} }, .{ "kvafjord.no", {} }, .{ "kvalsund.no", {} }, .{ "kvam.no", {} }, .{ "kvanangen.no", {} }, .{ "kvinesdal.no", {} }, .{ "kvinnherad.no", {} }, .{ "kviteseid.no", {} }, .{ "kvitsoy.no", {} }, .{ "kvitsøy.no", {} }, .{ "laakesvuemie.no", {} }, .{ "lærdal.no", {} }, .{ "lahppi.no", {} }, .{ "láhppi.no", {} }, .{ "lardal.no", {} }, .{ "larvik.no", {} }, .{ "lavagis.no", {} }, .{ "lavangen.no", {} }, .{ "leangaviika.no", {} }, .{ "leaŋgaviika.no", {} }, .{ "lebesby.no", {} }, .{ "leikanger.no", {} }, .{ "leirfjord.no", {} }, .{ "leka.no", {} }, .{ "leksvik.no", {} }, .{ "lenvik.no", {} }, .{ "lerdal.no", {} }, .{ "lesja.no", {} }, .{ "levanger.no", {} }, .{ "lier.no", {} }, .{ "lierne.no", {} }, .{ "lillehammer.no", {} }, .{ "lillesand.no", {} }, .{ "lindas.no", {} }, .{ "lindås.no", {} }, .{ "lindesnes.no", {} }, .{ "loabat.no", {} }, .{ "loabát.no", {} }, .{ "lodingen.no", {} }, .{ "lødingen.no", {} }, .{ "lom.no", {} }, .{ "loppa.no", {} }, .{ "lorenskog.no", {} }, .{ "lørenskog.no", {} }, .{ "loten.no", {} }, .{ "løten.no", {} }, .{ "lund.no", {} }, .{ "lunner.no", {} }, .{ "luroy.no", {} }, .{ "lurøy.no", {} }, .{ "luster.no", {} }, .{ "lyngdal.no", {} }, .{ "lyngen.no", {} }, .{ "malatvuopmi.no", {} }, .{ "málatvuopmi.no", {} }, .{ "malselv.no", {} }, .{ "målselv.no", {} }, .{ "malvik.no", {} }, .{ "mandal.no", {} }, .{ "marker.no", {} }, .{ "marnardal.no", {} }, .{ "masfjorden.no", {} }, .{ "masoy.no", {} }, .{ "måsøy.no", {} }, .{ "matta-varjjat.no", {} }, .{ "mátta-várjjat.no", {} }, .{ "meland.no", {} }, .{ "meldal.no", {} }, .{ "melhus.no", {} }, .{ "meloy.no", {} }, .{ "meløy.no", {} }, .{ "meraker.no", {} }, .{ "meråker.no", {} }, .{ "midsund.no", {} }, .{ "midtre-gauldal.no", {} }, .{ "moareke.no", {} }, .{ "moåreke.no", {} }, .{ "modalen.no", {} }, .{ "modum.no", {} }, .{ "molde.no", {} }, .{ "heroy.more-og-romsdal.no", {} }, .{ "sande.more-og-romsdal.no", {} }, .{ "herøy.møre-og-romsdal.no", {} }, .{ "sande.møre-og-romsdal.no", {} }, .{ "moskenes.no", {} }, .{ "moss.no", {} }, .{ "muosat.no", {} }, .{ "muosát.no", {} }, .{ "naamesjevuemie.no", {} }, .{ "nååmesjevuemie.no", {} }, .{ "nærøy.no", {} }, .{ "namdalseid.no", {} }, .{ "namsos.no", {} }, .{ "namsskogan.no", {} }, .{ "nannestad.no", {} }, .{ "naroy.no", {} }, .{ "narviika.no", {} }, .{ "narvik.no", {} }, .{ "naustdal.no", {} }, .{ "navuotna.no", {} }, .{ "návuotna.no", {} }, .{ "nedre-eiker.no", {} }, .{ "nesna.no", {} }, .{ "nesodden.no", {} }, .{ "nesseby.no", {} }, .{ "nesset.no", {} }, .{ "nissedal.no", {} }, .{ "nittedal.no", {} }, .{ "nord-aurdal.no", {} }, .{ "nord-fron.no", {} }, .{ "nord-odal.no", {} }, .{ "norddal.no", {} }, .{ "nordkapp.no", {} }, .{ "bo.nordland.no", {} }, .{ "bø.nordland.no", {} }, .{ "heroy.nordland.no", {} }, .{ "herøy.nordland.no", {} }, .{ "nordre-land.no", {} }, .{ "nordreisa.no", {} }, .{ "nore-og-uvdal.no", {} }, .{ "notodden.no", {} }, .{ "notteroy.no", {} }, .{ "nøtterøy.no", {} }, .{ "odda.no", {} }, .{ "oksnes.no", {} }, .{ "øksnes.no", {} }, .{ "omasvuotna.no", {} }, .{ "oppdal.no", {} }, .{ "oppegard.no", {} }, .{ "oppegård.no", {} }, .{ "orkdal.no", {} }, .{ "orland.no", {} }, .{ "ørland.no", {} }, .{ "orskog.no", {} }, .{ "ørskog.no", {} }, .{ "orsta.no", {} }, .{ "ørsta.no", {} }, .{ "osen.no", {} }, .{ "osteroy.no", {} }, .{ "osterøy.no", {} }, .{ "valer.ostfold.no", {} }, .{ "våler.østfold.no", {} }, .{ "ostre-toten.no", {} }, .{ "østre-toten.no", {} }, .{ "overhalla.no", {} }, .{ "ovre-eiker.no", {} }, .{ "øvre-eiker.no", {} }, .{ "oyer.no", {} }, .{ "øyer.no", {} }, .{ "oygarden.no", {} }, .{ "øygarden.no", {} }, .{ "oystre-slidre.no", {} }, .{ "øystre-slidre.no", {} }, .{ "porsanger.no", {} }, .{ "porsangu.no", {} }, .{ "porsáŋgu.no", {} }, .{ "porsgrunn.no", {} }, .{ "rade.no", {} }, .{ "råde.no", {} }, .{ "radoy.no", {} }, .{ "radøy.no", {} }, .{ "rælingen.no", {} }, .{ "rahkkeravju.no", {} }, .{ "ráhkkerávju.no", {} }, .{ "raisa.no", {} }, .{ "ráisa.no", {} }, .{ "rakkestad.no", {} }, .{ "ralingen.no", {} }, .{ "rana.no", {} }, .{ "randaberg.no", {} }, .{ "rauma.no", {} }, .{ "rendalen.no", {} }, .{ "rennebu.no", {} }, .{ "rennesoy.no", {} }, .{ "rennesøy.no", {} }, .{ "rindal.no", {} }, .{ "ringebu.no", {} }, .{ "ringerike.no", {} }, .{ "ringsaker.no", {} }, .{ "risor.no", {} }, .{ "risør.no", {} }, .{ "rissa.no", {} }, .{ "roan.no", {} }, .{ "rodoy.no", {} }, .{ "rødøy.no", {} }, .{ "rollag.no", {} }, .{ "romsa.no", {} }, .{ "romskog.no", {} }, .{ "rømskog.no", {} }, .{ "roros.no", {} }, .{ "røros.no", {} }, .{ "rost.no", {} }, .{ "røst.no", {} }, .{ "royken.no", {} }, .{ "røyken.no", {} }, .{ "royrvik.no", {} }, .{ "røyrvik.no", {} }, .{ "ruovat.no", {} }, .{ "rygge.no", {} }, .{ "salangen.no", {} }, .{ "salat.no", {} }, .{ "sálat.no", {} }, .{ "sálát.no", {} }, .{ "saltdal.no", {} }, .{ "samnanger.no", {} }, .{ "sandefjord.no", {} }, .{ "sandnes.no", {} }, .{ "sandoy.no", {} }, .{ "sandøy.no", {} }, .{ "sarpsborg.no", {} }, .{ "sauda.no", {} }, .{ "sauherad.no", {} }, .{ "sel.no", {} }, .{ "selbu.no", {} }, .{ "selje.no", {} }, .{ "seljord.no", {} }, .{ "siellak.no", {} }, .{ "sigdal.no", {} }, .{ "siljan.no", {} }, .{ "sirdal.no", {} }, .{ "skanit.no", {} }, .{ "skánit.no", {} }, .{ "skanland.no", {} }, .{ "skånland.no", {} }, .{ "skaun.no", {} }, .{ "skedsmo.no", {} }, .{ "ski.no", {} }, .{ "skien.no", {} }, .{ "skierva.no", {} }, .{ "skiervá.no", {} }, .{ "skiptvet.no", {} }, .{ "skjak.no", {} }, .{ "skjåk.no", {} }, .{ "skjervoy.no", {} }, .{ "skjervøy.no", {} }, .{ "skodje.no", {} }, .{ "smola.no", {} }, .{ "smøla.no", {} }, .{ "snaase.no", {} }, .{ "snåase.no", {} }, .{ "snasa.no", {} }, .{ "snåsa.no", {} }, .{ "snillfjord.no", {} }, .{ "snoasa.no", {} }, .{ "sogndal.no", {} }, .{ "sogne.no", {} }, .{ "søgne.no", {} }, .{ "sokndal.no", {} }, .{ "sola.no", {} }, .{ "solund.no", {} }, .{ "somna.no", {} }, .{ "sømna.no", {} }, .{ "sondre-land.no", {} }, .{ "søndre-land.no", {} }, .{ "songdalen.no", {} }, .{ "sor-aurdal.no", {} }, .{ "sør-aurdal.no", {} }, .{ "sor-fron.no", {} }, .{ "sør-fron.no", {} }, .{ "sor-odal.no", {} }, .{ "sør-odal.no", {} }, .{ "sor-varanger.no", {} }, .{ "sør-varanger.no", {} }, .{ "sorfold.no", {} }, .{ "sørfold.no", {} }, .{ "sorreisa.no", {} }, .{ "sørreisa.no", {} }, .{ "sortland.no", {} }, .{ "sorum.no", {} }, .{ "sørum.no", {} }, .{ "spydeberg.no", {} }, .{ "stange.no", {} }, .{ "stavanger.no", {} }, .{ "steigen.no", {} }, .{ "steinkjer.no", {} }, .{ "stjordal.no", {} }, .{ "stjørdal.no", {} }, .{ "stokke.no", {} }, .{ "stor-elvdal.no", {} }, .{ "stord.no", {} }, .{ "stordal.no", {} }, .{ "storfjord.no", {} }, .{ "strand.no", {} }, .{ "stranda.no", {} }, .{ "stryn.no", {} }, .{ "sula.no", {} }, .{ "suldal.no", {} }, .{ "sund.no", {} }, .{ "sunndal.no", {} }, .{ "surnadal.no", {} }, .{ "sveio.no", {} }, .{ "svelvik.no", {} }, .{ "sykkylven.no", {} }, .{ "tana.no", {} }, .{ "bo.telemark.no", {} }, .{ "bø.telemark.no", {} }, .{ "time.no", {} }, .{ "tingvoll.no", {} }, .{ "tinn.no", {} }, .{ "tjeldsund.no", {} }, .{ "tjome.no", {} }, .{ "tjøme.no", {} }, .{ "tokke.no", {} }, .{ "tolga.no", {} }, .{ "tonsberg.no", {} }, .{ "tønsberg.no", {} }, .{ "torsken.no", {} }, .{ "træna.no", {} }, .{ "trana.no", {} }, .{ "tranoy.no", {} }, .{ "tranøy.no", {} }, .{ "troandin.no", {} }, .{ "trogstad.no", {} }, .{ "trøgstad.no", {} }, .{ "tromsa.no", {} }, .{ "tromso.no", {} }, .{ "tromsø.no", {} }, .{ "trondheim.no", {} }, .{ "trysil.no", {} }, .{ "tvedestrand.no", {} }, .{ "tydal.no", {} }, .{ "tynset.no", {} }, .{ "tysfjord.no", {} }, .{ "tysnes.no", {} }, .{ "tysvær.no", {} }, .{ "tysvar.no", {} }, .{ "ullensaker.no", {} }, .{ "ullensvang.no", {} }, .{ "ulvik.no", {} }, .{ "unjarga.no", {} }, .{ "unjárga.no", {} }, .{ "utsira.no", {} }, .{ "vaapste.no", {} }, .{ "vadso.no", {} }, .{ "vadsø.no", {} }, .{ "værøy.no", {} }, .{ "vaga.no", {} }, .{ "vågå.no", {} }, .{ "vagan.no", {} }, .{ "vågan.no", {} }, .{ "vagsoy.no", {} }, .{ "vågsøy.no", {} }, .{ "vaksdal.no", {} }, .{ "valle.no", {} }, .{ "vang.no", {} }, .{ "vanylven.no", {} }, .{ "vardo.no", {} }, .{ "vardø.no", {} }, .{ "varggat.no", {} }, .{ "várggát.no", {} }, .{ "varoy.no", {} }, .{ "vefsn.no", {} }, .{ "vega.no", {} }, .{ "vegarshei.no", {} }, .{ "vegårshei.no", {} }, .{ "vennesla.no", {} }, .{ "verdal.no", {} }, .{ "verran.no", {} }, .{ "vestby.no", {} }, .{ "sande.vestfold.no", {} }, .{ "vestnes.no", {} }, .{ "vestre-slidre.no", {} }, .{ "vestre-toten.no", {} }, .{ "vestvagoy.no", {} }, .{ "vestvågøy.no", {} }, .{ "vevelstad.no", {} }, .{ "vik.no", {} }, .{ "vikna.no", {} }, .{ "vindafjord.no", {} }, .{ "voagat.no", {} }, .{ "volda.no", {} }, .{ "voss.no", {} }, .{ "*.np", {} }, .{ "nr", {} }, .{ "biz.nr", {} }, .{ "com.nr", {} }, .{ "edu.nr", {} }, .{ "gov.nr", {} }, .{ "info.nr", {} }, .{ "net.nr", {} }, .{ "org.nr", {} }, .{ "nu", {} }, .{ "nz", {} }, .{ "ac.nz", {} }, .{ "co.nz", {} }, .{ "cri.nz", {} }, .{ "geek.nz", {} }, .{ "gen.nz", {} }, .{ "govt.nz", {} }, .{ "health.nz", {} }, .{ "iwi.nz", {} }, .{ "kiwi.nz", {} }, .{ "maori.nz", {} }, .{ "māori.nz", {} }, .{ "mil.nz", {} }, .{ "net.nz", {} }, .{ "org.nz", {} }, .{ "parliament.nz", {} }, .{ "school.nz", {} }, .{ "om", {} }, .{ "co.om", {} }, .{ "com.om", {} }, .{ "edu.om", {} }, .{ "gov.om", {} }, .{ "med.om", {} }, .{ "museum.om", {} }, .{ "net.om", {} }, .{ "org.om", {} }, .{ "pro.om", {} }, .{ "onion", {} }, .{ "org", {} }, .{ "pa", {} }, .{ "abo.pa", {} }, .{ "ac.pa", {} }, .{ "com.pa", {} }, .{ "edu.pa", {} }, .{ "gob.pa", {} }, .{ "ing.pa", {} }, .{ "med.pa", {} }, .{ "net.pa", {} }, .{ "nom.pa", {} }, .{ "org.pa", {} }, .{ "sld.pa", {} }, .{ "pe", {} }, .{ "com.pe", {} }, .{ "edu.pe", {} }, .{ "gob.pe", {} }, .{ "mil.pe", {} }, .{ "net.pe", {} }, .{ "nom.pe", {} }, .{ "org.pe", {} }, .{ "pf", {} }, .{ "com.pf", {} }, .{ "edu.pf", {} }, .{ "org.pf", {} }, .{ "*.pg", {} }, .{ "ph", {} }, .{ "com.ph", {} }, .{ "edu.ph", {} }, .{ "gov.ph", {} }, .{ "i.ph", {} }, .{ "mil.ph", {} }, .{ "net.ph", {} }, .{ "ngo.ph", {} }, .{ "org.ph", {} }, .{ "pk", {} }, .{ "ac.pk", {} }, .{ "biz.pk", {} }, .{ "com.pk", {} }, .{ "edu.pk", {} }, .{ "fam.pk", {} }, .{ "gkp.pk", {} }, .{ "gob.pk", {} }, .{ "gog.pk", {} }, .{ "gok.pk", {} }, .{ "gop.pk", {} }, .{ "gos.pk", {} }, .{ "gov.pk", {} }, .{ "net.pk", {} }, .{ "org.pk", {} }, .{ "web.pk", {} }, .{ "pl", {} }, .{ "com.pl", {} }, .{ "net.pl", {} }, .{ "org.pl", {} }, .{ "agro.pl", {} }, .{ "aid.pl", {} }, .{ "atm.pl", {} }, .{ "auto.pl", {} }, .{ "biz.pl", {} }, .{ "edu.pl", {} }, .{ "gmina.pl", {} }, .{ "gsm.pl", {} }, .{ "info.pl", {} }, .{ "mail.pl", {} }, .{ "media.pl", {} }, .{ "miasta.pl", {} }, .{ "mil.pl", {} }, .{ "nieruchomosci.pl", {} }, .{ "nom.pl", {} }, .{ "pc.pl", {} }, .{ "powiat.pl", {} }, .{ "priv.pl", {} }, .{ "realestate.pl", {} }, .{ "rel.pl", {} }, .{ "sex.pl", {} }, .{ "shop.pl", {} }, .{ "sklep.pl", {} }, .{ "sos.pl", {} }, .{ "szkola.pl", {} }, .{ "targi.pl", {} }, .{ "tm.pl", {} }, .{ "tourism.pl", {} }, .{ "travel.pl", {} }, .{ "turystyka.pl", {} }, .{ "gov.pl", {} }, .{ "ap.gov.pl", {} }, .{ "griw.gov.pl", {} }, .{ "ic.gov.pl", {} }, .{ "is.gov.pl", {} }, .{ "kmpsp.gov.pl", {} }, .{ "konsulat.gov.pl", {} }, .{ "kppsp.gov.pl", {} }, .{ "kwp.gov.pl", {} }, .{ "kwpsp.gov.pl", {} }, .{ "mup.gov.pl", {} }, .{ "mw.gov.pl", {} }, .{ "oia.gov.pl", {} }, .{ "oirm.gov.pl", {} }, .{ "oke.gov.pl", {} }, .{ "oow.gov.pl", {} }, .{ "oschr.gov.pl", {} }, .{ "oum.gov.pl", {} }, .{ "pa.gov.pl", {} }, .{ "pinb.gov.pl", {} }, .{ "piw.gov.pl", {} }, .{ "po.gov.pl", {} }, .{ "pr.gov.pl", {} }, .{ "psp.gov.pl", {} }, .{ "psse.gov.pl", {} }, .{ "pup.gov.pl", {} }, .{ "rzgw.gov.pl", {} }, .{ "sa.gov.pl", {} }, .{ "sdn.gov.pl", {} }, .{ "sko.gov.pl", {} }, .{ "so.gov.pl", {} }, .{ "sr.gov.pl", {} }, .{ "starostwo.gov.pl", {} }, .{ "ug.gov.pl", {} }, .{ "ugim.gov.pl", {} }, .{ "um.gov.pl", {} }, .{ "umig.gov.pl", {} }, .{ "upow.gov.pl", {} }, .{ "uppo.gov.pl", {} }, .{ "us.gov.pl", {} }, .{ "uw.gov.pl", {} }, .{ "uzs.gov.pl", {} }, .{ "wif.gov.pl", {} }, .{ "wiih.gov.pl", {} }, .{ "winb.gov.pl", {} }, .{ "wios.gov.pl", {} }, .{ "witd.gov.pl", {} }, .{ "wiw.gov.pl", {} }, .{ "wkz.gov.pl", {} }, .{ "wsa.gov.pl", {} }, .{ "wskr.gov.pl", {} }, .{ "wsse.gov.pl", {} }, .{ "wuoz.gov.pl", {} }, .{ "wzmiuw.gov.pl", {} }, .{ "zp.gov.pl", {} }, .{ "zpisdn.gov.pl", {} }, .{ "augustow.pl", {} }, .{ "babia-gora.pl", {} }, .{ "bedzin.pl", {} }, .{ "beskidy.pl", {} }, .{ "bialowieza.pl", {} }, .{ "bialystok.pl", {} }, .{ "bielawa.pl", {} }, .{ "bieszczady.pl", {} }, .{ "boleslawiec.pl", {} }, .{ "bydgoszcz.pl", {} }, .{ "bytom.pl", {} }, .{ "cieszyn.pl", {} }, .{ "czeladz.pl", {} }, .{ "czest.pl", {} }, .{ "dlugoleka.pl", {} }, .{ "elblag.pl", {} }, .{ "elk.pl", {} }, .{ "glogow.pl", {} }, .{ "gniezno.pl", {} }, .{ "gorlice.pl", {} }, .{ "grajewo.pl", {} }, .{ "ilawa.pl", {} }, .{ "jaworzno.pl", {} }, .{ "jelenia-gora.pl", {} }, .{ "jgora.pl", {} }, .{ "kalisz.pl", {} }, .{ "karpacz.pl", {} }, .{ "kartuzy.pl", {} }, .{ "kaszuby.pl", {} }, .{ "katowice.pl", {} }, .{ "kazimierz-dolny.pl", {} }, .{ "kepno.pl", {} }, .{ "ketrzyn.pl", {} }, .{ "klodzko.pl", {} }, .{ "kobierzyce.pl", {} }, .{ "kolobrzeg.pl", {} }, .{ "konin.pl", {} }, .{ "konskowola.pl", {} }, .{ "kutno.pl", {} }, .{ "lapy.pl", {} }, .{ "lebork.pl", {} }, .{ "legnica.pl", {} }, .{ "lezajsk.pl", {} }, .{ "limanowa.pl", {} }, .{ "lomza.pl", {} }, .{ "lowicz.pl", {} }, .{ "lubin.pl", {} }, .{ "lukow.pl", {} }, .{ "malbork.pl", {} }, .{ "malopolska.pl", {} }, .{ "mazowsze.pl", {} }, .{ "mazury.pl", {} }, .{ "mielec.pl", {} }, .{ "mielno.pl", {} }, .{ "mragowo.pl", {} }, .{ "naklo.pl", {} }, .{ "nowaruda.pl", {} }, .{ "nysa.pl", {} }, .{ "olawa.pl", {} }, .{ "olecko.pl", {} }, .{ "olkusz.pl", {} }, .{ "olsztyn.pl", {} }, .{ "opoczno.pl", {} }, .{ "opole.pl", {} }, .{ "ostroda.pl", {} }, .{ "ostroleka.pl", {} }, .{ "ostrowiec.pl", {} }, .{ "ostrowwlkp.pl", {} }, .{ "pila.pl", {} }, .{ "pisz.pl", {} }, .{ "podhale.pl", {} }, .{ "podlasie.pl", {} }, .{ "polkowice.pl", {} }, .{ "pomorskie.pl", {} }, .{ "pomorze.pl", {} }, .{ "prochowice.pl", {} }, .{ "pruszkow.pl", {} }, .{ "przeworsk.pl", {} }, .{ "pulawy.pl", {} }, .{ "radom.pl", {} }, .{ "rawa-maz.pl", {} }, .{ "rybnik.pl", {} }, .{ "rzeszow.pl", {} }, .{ "sanok.pl", {} }, .{ "sejny.pl", {} }, .{ "skoczow.pl", {} }, .{ "slask.pl", {} }, .{ "slupsk.pl", {} }, .{ "sosnowiec.pl", {} }, .{ "stalowa-wola.pl", {} }, .{ "starachowice.pl", {} }, .{ "stargard.pl", {} }, .{ "suwalki.pl", {} }, .{ "swidnica.pl", {} }, .{ "swiebodzin.pl", {} }, .{ "swinoujscie.pl", {} }, .{ "szczecin.pl", {} }, .{ "szczytno.pl", {} }, .{ "tarnobrzeg.pl", {} }, .{ "tgory.pl", {} }, .{ "turek.pl", {} }, .{ "tychy.pl", {} }, .{ "ustka.pl", {} }, .{ "walbrzych.pl", {} }, .{ "warmia.pl", {} }, .{ "warszawa.pl", {} }, .{ "waw.pl", {} }, .{ "wegrow.pl", {} }, .{ "wielun.pl", {} }, .{ "wlocl.pl", {} }, .{ "wloclawek.pl", {} }, .{ "wodzislaw.pl", {} }, .{ "wolomin.pl", {} }, .{ "wroclaw.pl", {} }, .{ "zachpomor.pl", {} }, .{ "zagan.pl", {} }, .{ "zarow.pl", {} }, .{ "zgora.pl", {} }, .{ "zgorzelec.pl", {} }, .{ "pm", {} }, .{ "pn", {} }, .{ "co.pn", {} }, .{ "edu.pn", {} }, .{ "gov.pn", {} }, .{ "net.pn", {} }, .{ "org.pn", {} }, .{ "post", {} }, .{ "pr", {} }, .{ "biz.pr", {} }, .{ "com.pr", {} }, .{ "edu.pr", {} }, .{ "gov.pr", {} }, .{ "info.pr", {} }, .{ "isla.pr", {} }, .{ "name.pr", {} }, .{ "net.pr", {} }, .{ "org.pr", {} }, .{ "pro.pr", {} }, .{ "ac.pr", {} }, .{ "est.pr", {} }, .{ "prof.pr", {} }, .{ "pro", {} }, .{ "aaa.pro", {} }, .{ "aca.pro", {} }, .{ "acct.pro", {} }, .{ "avocat.pro", {} }, .{ "bar.pro", {} }, .{ "cpa.pro", {} }, .{ "eng.pro", {} }, .{ "jur.pro", {} }, .{ "law.pro", {} }, .{ "med.pro", {} }, .{ "recht.pro", {} }, .{ "ps", {} }, .{ "com.ps", {} }, .{ "edu.ps", {} }, .{ "gov.ps", {} }, .{ "net.ps", {} }, .{ "org.ps", {} }, .{ "plo.ps", {} }, .{ "sec.ps", {} }, .{ "pt", {} }, .{ "com.pt", {} }, .{ "edu.pt", {} }, .{ "gov.pt", {} }, .{ "int.pt", {} }, .{ "net.pt", {} }, .{ "nome.pt", {} }, .{ "org.pt", {} }, .{ "publ.pt", {} }, .{ "pw", {} }, .{ "gov.pw", {} }, .{ "py", {} }, .{ "com.py", {} }, .{ "coop.py", {} }, .{ "edu.py", {} }, .{ "gov.py", {} }, .{ "mil.py", {} }, .{ "net.py", {} }, .{ "org.py", {} }, .{ "qa", {} }, .{ "com.qa", {} }, .{ "edu.qa", {} }, .{ "gov.qa", {} }, .{ "mil.qa", {} }, .{ "name.qa", {} }, .{ "net.qa", {} }, .{ "org.qa", {} }, .{ "sch.qa", {} }, .{ "re", {} }, .{ "asso.re", {} }, .{ "com.re", {} }, .{ "ro", {} }, .{ "arts.ro", {} }, .{ "com.ro", {} }, .{ "firm.ro", {} }, .{ "info.ro", {} }, .{ "nom.ro", {} }, .{ "nt.ro", {} }, .{ "org.ro", {} }, .{ "rec.ro", {} }, .{ "store.ro", {} }, .{ "tm.ro", {} }, .{ "www.ro", {} }, .{ "rs", {} }, .{ "ac.rs", {} }, .{ "co.rs", {} }, .{ "edu.rs", {} }, .{ "gov.rs", {} }, .{ "in.rs", {} }, .{ "org.rs", {} }, .{ "ru", {} }, .{ "rw", {} }, .{ "ac.rw", {} }, .{ "co.rw", {} }, .{ "coop.rw", {} }, .{ "gov.rw", {} }, .{ "mil.rw", {} }, .{ "net.rw", {} }, .{ "org.rw", {} }, .{ "sa", {} }, .{ "com.sa", {} }, .{ "edu.sa", {} }, .{ "gov.sa", {} }, .{ "med.sa", {} }, .{ "net.sa", {} }, .{ "org.sa", {} }, .{ "pub.sa", {} }, .{ "sch.sa", {} }, .{ "sb", {} }, .{ "com.sb", {} }, .{ "edu.sb", {} }, .{ "gov.sb", {} }, .{ "net.sb", {} }, .{ "org.sb", {} }, .{ "sc", {} }, .{ "com.sc", {} }, .{ "edu.sc", {} }, .{ "gov.sc", {} }, .{ "net.sc", {} }, .{ "org.sc", {} }, .{ "sd", {} }, .{ "com.sd", {} }, .{ "edu.sd", {} }, .{ "gov.sd", {} }, .{ "info.sd", {} }, .{ "med.sd", {} }, .{ "net.sd", {} }, .{ "org.sd", {} }, .{ "tv.sd", {} }, .{ "se", {} }, .{ "a.se", {} }, .{ "ac.se", {} }, .{ "b.se", {} }, .{ "bd.se", {} }, .{ "brand.se", {} }, .{ "c.se", {} }, .{ "d.se", {} }, .{ "e.se", {} }, .{ "f.se", {} }, .{ "fh.se", {} }, .{ "fhsk.se", {} }, .{ "fhv.se", {} }, .{ "g.se", {} }, .{ "h.se", {} }, .{ "i.se", {} }, .{ "k.se", {} }, .{ "komforb.se", {} }, .{ "kommunalforbund.se", {} }, .{ "komvux.se", {} }, .{ "l.se", {} }, .{ "lanbib.se", {} }, .{ "m.se", {} }, .{ "n.se", {} }, .{ "naturbruksgymn.se", {} }, .{ "o.se", {} }, .{ "org.se", {} }, .{ "p.se", {} }, .{ "parti.se", {} }, .{ "pp.se", {} }, .{ "press.se", {} }, .{ "r.se", {} }, .{ "s.se", {} }, .{ "t.se", {} }, .{ "tm.se", {} }, .{ "u.se", {} }, .{ "w.se", {} }, .{ "x.se", {} }, .{ "y.se", {} }, .{ "z.se", {} }, .{ "sg", {} }, .{ "com.sg", {} }, .{ "edu.sg", {} }, .{ "gov.sg", {} }, .{ "net.sg", {} }, .{ "org.sg", {} }, .{ "sh", {} }, .{ "com.sh", {} }, .{ "gov.sh", {} }, .{ "mil.sh", {} }, .{ "net.sh", {} }, .{ "org.sh", {} }, .{ "si", {} }, .{ "sj", {} }, .{ "sk", {} }, .{ "org.sk", {} }, .{ "sl", {} }, .{ "com.sl", {} }, .{ "edu.sl", {} }, .{ "gov.sl", {} }, .{ "net.sl", {} }, .{ "org.sl", {} }, .{ "sm", {} }, .{ "sn", {} }, .{ "art.sn", {} }, .{ "com.sn", {} }, .{ "edu.sn", {} }, .{ "gouv.sn", {} }, .{ "org.sn", {} }, .{ "univ.sn", {} }, .{ "so", {} }, .{ "com.so", {} }, .{ "edu.so", {} }, .{ "gov.so", {} }, .{ "me.so", {} }, .{ "net.so", {} }, .{ "org.so", {} }, .{ "sr", {} }, .{ "ss", {} }, .{ "biz.ss", {} }, .{ "co.ss", {} }, .{ "com.ss", {} }, .{ "edu.ss", {} }, .{ "gov.ss", {} }, .{ "me.ss", {} }, .{ "net.ss", {} }, .{ "org.ss", {} }, .{ "sch.ss", {} }, .{ "st", {} }, .{ "co.st", {} }, .{ "com.st", {} }, .{ "consulado.st", {} }, .{ "edu.st", {} }, .{ "embaixada.st", {} }, .{ "mil.st", {} }, .{ "net.st", {} }, .{ "org.st", {} }, .{ "principe.st", {} }, .{ "saotome.st", {} }, .{ "store.st", {} }, .{ "su", {} }, .{ "sv", {} }, .{ "com.sv", {} }, .{ "edu.sv", {} }, .{ "gob.sv", {} }, .{ "org.sv", {} }, .{ "red.sv", {} }, .{ "sx", {} }, .{ "gov.sx", {} }, .{ "sy", {} }, .{ "com.sy", {} }, .{ "edu.sy", {} }, .{ "gov.sy", {} }, .{ "mil.sy", {} }, .{ "net.sy", {} }, .{ "org.sy", {} }, .{ "sz", {} }, .{ "ac.sz", {} }, .{ "co.sz", {} }, .{ "org.sz", {} }, .{ "tc", {} }, .{ "td", {} }, .{ "tel", {} }, .{ "tf", {} }, .{ "tg", {} }, .{ "th", {} }, .{ "ac.th", {} }, .{ "co.th", {} }, .{ "go.th", {} }, .{ "in.th", {} }, .{ "mi.th", {} }, .{ "net.th", {} }, .{ "or.th", {} }, .{ "tj", {} }, .{ "ac.tj", {} }, .{ "biz.tj", {} }, .{ "co.tj", {} }, .{ "com.tj", {} }, .{ "edu.tj", {} }, .{ "go.tj", {} }, .{ "gov.tj", {} }, .{ "int.tj", {} }, .{ "mil.tj", {} }, .{ "name.tj", {} }, .{ "net.tj", {} }, .{ "nic.tj", {} }, .{ "org.tj", {} }, .{ "test.tj", {} }, .{ "web.tj", {} }, .{ "tk", {} }, .{ "tl", {} }, .{ "gov.tl", {} }, .{ "tm", {} }, .{ "co.tm", {} }, .{ "com.tm", {} }, .{ "edu.tm", {} }, .{ "gov.tm", {} }, .{ "mil.tm", {} }, .{ "net.tm", {} }, .{ "nom.tm", {} }, .{ "org.tm", {} }, .{ "tn", {} }, .{ "com.tn", {} }, .{ "ens.tn", {} }, .{ "fin.tn", {} }, .{ "gov.tn", {} }, .{ "ind.tn", {} }, .{ "info.tn", {} }, .{ "intl.tn", {} }, .{ "mincom.tn", {} }, .{ "nat.tn", {} }, .{ "net.tn", {} }, .{ "org.tn", {} }, .{ "perso.tn", {} }, .{ "tourism.tn", {} }, .{ "to", {} }, .{ "com.to", {} }, .{ "edu.to", {} }, .{ "gov.to", {} }, .{ "mil.to", {} }, .{ "net.to", {} }, .{ "org.to", {} }, .{ "tr", {} }, .{ "av.tr", {} }, .{ "bbs.tr", {} }, .{ "bel.tr", {} }, .{ "biz.tr", {} }, .{ "com.tr", {} }, .{ "dr.tr", {} }, .{ "edu.tr", {} }, .{ "gen.tr", {} }, .{ "gov.tr", {} }, .{ "info.tr", {} }, .{ "k12.tr", {} }, .{ "kep.tr", {} }, .{ "mil.tr", {} }, .{ "name.tr", {} }, .{ "net.tr", {} }, .{ "org.tr", {} }, .{ "pol.tr", {} }, .{ "tel.tr", {} }, .{ "tsk.tr", {} }, .{ "tv.tr", {} }, .{ "web.tr", {} }, .{ "nc.tr", {} }, .{ "gov.nc.tr", {} }, .{ "tt", {} }, .{ "biz.tt", {} }, .{ "co.tt", {} }, .{ "com.tt", {} }, .{ "edu.tt", {} }, .{ "gov.tt", {} }, .{ "info.tt", {} }, .{ "mil.tt", {} }, .{ "name.tt", {} }, .{ "net.tt", {} }, .{ "org.tt", {} }, .{ "pro.tt", {} }, .{ "tv", {} }, .{ "tw", {} }, .{ "club.tw", {} }, .{ "com.tw", {} }, .{ "ebiz.tw", {} }, .{ "edu.tw", {} }, .{ "game.tw", {} }, .{ "gov.tw", {} }, .{ "idv.tw", {} }, .{ "mil.tw", {} }, .{ "net.tw", {} }, .{ "org.tw", {} }, .{ "tz", {} }, .{ "ac.tz", {} }, .{ "co.tz", {} }, .{ "go.tz", {} }, .{ "hotel.tz", {} }, .{ "info.tz", {} }, .{ "me.tz", {} }, .{ "mil.tz", {} }, .{ "mobi.tz", {} }, .{ "ne.tz", {} }, .{ "or.tz", {} }, .{ "sc.tz", {} }, .{ "tv.tz", {} }, .{ "ua", {} }, .{ "com.ua", {} }, .{ "edu.ua", {} }, .{ "gov.ua", {} }, .{ "in.ua", {} }, .{ "net.ua", {} }, .{ "org.ua", {} }, .{ "cherkassy.ua", {} }, .{ "cherkasy.ua", {} }, .{ "chernigov.ua", {} }, .{ "chernihiv.ua", {} }, .{ "chernivtsi.ua", {} }, .{ "chernovtsy.ua", {} }, .{ "ck.ua", {} }, .{ "cn.ua", {} }, .{ "cr.ua", {} }, .{ "crimea.ua", {} }, .{ "cv.ua", {} }, .{ "dn.ua", {} }, .{ "dnepropetrovsk.ua", {} }, .{ "dnipropetrovsk.ua", {} }, .{ "donetsk.ua", {} }, .{ "dp.ua", {} }, .{ "if.ua", {} }, .{ "ivano-frankivsk.ua", {} }, .{ "kh.ua", {} }, .{ "kharkiv.ua", {} }, .{ "kharkov.ua", {} }, .{ "kherson.ua", {} }, .{ "khmelnitskiy.ua", {} }, .{ "khmelnytskyi.ua", {} }, .{ "kiev.ua", {} }, .{ "kirovograd.ua", {} }, .{ "km.ua", {} }, .{ "kr.ua", {} }, .{ "kropyvnytskyi.ua", {} }, .{ "krym.ua", {} }, .{ "ks.ua", {} }, .{ "kv.ua", {} }, .{ "kyiv.ua", {} }, .{ "lg.ua", {} }, .{ "lt.ua", {} }, .{ "lugansk.ua", {} }, .{ "luhansk.ua", {} }, .{ "lutsk.ua", {} }, .{ "lv.ua", {} }, .{ "lviv.ua", {} }, .{ "mk.ua", {} }, .{ "mykolaiv.ua", {} }, .{ "nikolaev.ua", {} }, .{ "od.ua", {} }, .{ "odesa.ua", {} }, .{ "odessa.ua", {} }, .{ "pl.ua", {} }, .{ "poltava.ua", {} }, .{ "rivne.ua", {} }, .{ "rovno.ua", {} }, .{ "rv.ua", {} }, .{ "sb.ua", {} }, .{ "sebastopol.ua", {} }, .{ "sevastopol.ua", {} }, .{ "sm.ua", {} }, .{ "sumy.ua", {} }, .{ "te.ua", {} }, .{ "ternopil.ua", {} }, .{ "uz.ua", {} }, .{ "uzhgorod.ua", {} }, .{ "uzhhorod.ua", {} }, .{ "vinnica.ua", {} }, .{ "vinnytsia.ua", {} }, .{ "vn.ua", {} }, .{ "volyn.ua", {} }, .{ "yalta.ua", {} }, .{ "zakarpattia.ua", {} }, .{ "zaporizhzhe.ua", {} }, .{ "zaporizhzhia.ua", {} }, .{ "zhitomir.ua", {} }, .{ "zhytomyr.ua", {} }, .{ "zp.ua", {} }, .{ "zt.ua", {} }, .{ "ug", {} }, .{ "ac.ug", {} }, .{ "co.ug", {} }, .{ "com.ug", {} }, .{ "edu.ug", {} }, .{ "go.ug", {} }, .{ "gov.ug", {} }, .{ "mil.ug", {} }, .{ "ne.ug", {} }, .{ "or.ug", {} }, .{ "org.ug", {} }, .{ "sc.ug", {} }, .{ "us.ug", {} }, .{ "uk", {} }, .{ "ac.uk", {} }, .{ "co.uk", {} }, .{ "gov.uk", {} }, .{ "ltd.uk", {} }, .{ "me.uk", {} }, .{ "net.uk", {} }, .{ "nhs.uk", {} }, .{ "org.uk", {} }, .{ "plc.uk", {} }, .{ "police.uk", {} }, .{ "*.sch.uk", {} }, .{ "us", {} }, .{ "dni.us", {} }, .{ "isa.us", {} }, .{ "nsn.us", {} }, .{ "ak.us", {} }, .{ "al.us", {} }, .{ "ar.us", {} }, .{ "as.us", {} }, .{ "az.us", {} }, .{ "ca.us", {} }, .{ "co.us", {} }, .{ "ct.us", {} }, .{ "dc.us", {} }, .{ "de.us", {} }, .{ "fl.us", {} }, .{ "ga.us", {} }, .{ "gu.us", {} }, .{ "hi.us", {} }, .{ "ia.us", {} }, .{ "id.us", {} }, .{ "il.us", {} }, .{ "in.us", {} }, .{ "ks.us", {} }, .{ "ky.us", {} }, .{ "la.us", {} }, .{ "ma.us", {} }, .{ "md.us", {} }, .{ "me.us", {} }, .{ "mi.us", {} }, .{ "mn.us", {} }, .{ "mo.us", {} }, .{ "ms.us", {} }, .{ "mt.us", {} }, .{ "nc.us", {} }, .{ "nd.us", {} }, .{ "ne.us", {} }, .{ "nh.us", {} }, .{ "nj.us", {} }, .{ "nm.us", {} }, .{ "nv.us", {} }, .{ "ny.us", {} }, .{ "oh.us", {} }, .{ "ok.us", {} }, .{ "or.us", {} }, .{ "pa.us", {} }, .{ "pr.us", {} }, .{ "ri.us", {} }, .{ "sc.us", {} }, .{ "sd.us", {} }, .{ "tn.us", {} }, .{ "tx.us", {} }, .{ "ut.us", {} }, .{ "va.us", {} }, .{ "vi.us", {} }, .{ "vt.us", {} }, .{ "wa.us", {} }, .{ "wi.us", {} }, .{ "wv.us", {} }, .{ "wy.us", {} }, .{ "k12.ak.us", {} }, .{ "k12.al.us", {} }, .{ "k12.ar.us", {} }, .{ "k12.as.us", {} }, .{ "k12.az.us", {} }, .{ "k12.ca.us", {} }, .{ "k12.co.us", {} }, .{ "k12.ct.us", {} }, .{ "k12.dc.us", {} }, .{ "k12.fl.us", {} }, .{ "k12.ga.us", {} }, .{ "k12.gu.us", {} }, .{ "k12.ia.us", {} }, .{ "k12.id.us", {} }, .{ "k12.il.us", {} }, .{ "k12.in.us", {} }, .{ "k12.ks.us", {} }, .{ "k12.ky.us", {} }, .{ "k12.la.us", {} }, .{ "k12.ma.us", {} }, .{ "k12.md.us", {} }, .{ "k12.me.us", {} }, .{ "k12.mi.us", {} }, .{ "k12.mn.us", {} }, .{ "k12.mo.us", {} }, .{ "k12.ms.us", {} }, .{ "k12.mt.us", {} }, .{ "k12.nc.us", {} }, .{ "k12.ne.us", {} }, .{ "k12.nh.us", {} }, .{ "k12.nj.us", {} }, .{ "k12.nm.us", {} }, .{ "k12.nv.us", {} }, .{ "k12.ny.us", {} }, .{ "k12.oh.us", {} }, .{ "k12.ok.us", {} }, .{ "k12.or.us", {} }, .{ "k12.pa.us", {} }, .{ "k12.pr.us", {} }, .{ "k12.sc.us", {} }, .{ "k12.tn.us", {} }, .{ "k12.tx.us", {} }, .{ "k12.ut.us", {} }, .{ "k12.va.us", {} }, .{ "k12.vi.us", {} }, .{ "k12.vt.us", {} }, .{ "k12.wa.us", {} }, .{ "k12.wi.us", {} }, .{ "cc.ak.us", {} }, .{ "lib.ak.us", {} }, .{ "cc.al.us", {} }, .{ "lib.al.us", {} }, .{ "cc.ar.us", {} }, .{ "lib.ar.us", {} }, .{ "cc.as.us", {} }, .{ "lib.as.us", {} }, .{ "cc.az.us", {} }, .{ "lib.az.us", {} }, .{ "cc.ca.us", {} }, .{ "lib.ca.us", {} }, .{ "cc.co.us", {} }, .{ "lib.co.us", {} }, .{ "cc.ct.us", {} }, .{ "lib.ct.us", {} }, .{ "cc.dc.us", {} }, .{ "lib.dc.us", {} }, .{ "cc.de.us", {} }, .{ "cc.fl.us", {} }, .{ "lib.fl.us", {} }, .{ "cc.ga.us", {} }, .{ "lib.ga.us", {} }, .{ "cc.gu.us", {} }, .{ "lib.gu.us", {} }, .{ "cc.hi.us", {} }, .{ "lib.hi.us", {} }, .{ "cc.ia.us", {} }, .{ "lib.ia.us", {} }, .{ "cc.id.us", {} }, .{ "lib.id.us", {} }, .{ "cc.il.us", {} }, .{ "lib.il.us", {} }, .{ "cc.in.us", {} }, .{ "lib.in.us", {} }, .{ "cc.ks.us", {} }, .{ "lib.ks.us", {} }, .{ "cc.ky.us", {} }, .{ "lib.ky.us", {} }, .{ "cc.la.us", {} }, .{ "lib.la.us", {} }, .{ "cc.ma.us", {} }, .{ "lib.ma.us", {} }, .{ "cc.md.us", {} }, .{ "lib.md.us", {} }, .{ "cc.me.us", {} }, .{ "lib.me.us", {} }, .{ "cc.mi.us", {} }, .{ "lib.mi.us", {} }, .{ "cc.mn.us", {} }, .{ "lib.mn.us", {} }, .{ "cc.mo.us", {} }, .{ "lib.mo.us", {} }, .{ "cc.ms.us", {} }, .{ "cc.mt.us", {} }, .{ "lib.mt.us", {} }, .{ "cc.nc.us", {} }, .{ "lib.nc.us", {} }, .{ "cc.nd.us", {} }, .{ "lib.nd.us", {} }, .{ "cc.ne.us", {} }, .{ "lib.ne.us", {} }, .{ "cc.nh.us", {} }, .{ "lib.nh.us", {} }, .{ "cc.nj.us", {} }, .{ "lib.nj.us", {} }, .{ "cc.nm.us", {} }, .{ "lib.nm.us", {} }, .{ "cc.nv.us", {} }, .{ "lib.nv.us", {} }, .{ "cc.ny.us", {} }, .{ "lib.ny.us", {} }, .{ "cc.oh.us", {} }, .{ "lib.oh.us", {} }, .{ "cc.ok.us", {} }, .{ "lib.ok.us", {} }, .{ "cc.or.us", {} }, .{ "lib.or.us", {} }, .{ "cc.pa.us", {} }, .{ "lib.pa.us", {} }, .{ "cc.pr.us", {} }, .{ "lib.pr.us", {} }, .{ "cc.ri.us", {} }, .{ "lib.ri.us", {} }, .{ "cc.sc.us", {} }, .{ "lib.sc.us", {} }, .{ "cc.sd.us", {} }, .{ "lib.sd.us", {} }, .{ "cc.tn.us", {} }, .{ "lib.tn.us", {} }, .{ "cc.tx.us", {} }, .{ "lib.tx.us", {} }, .{ "cc.ut.us", {} }, .{ "lib.ut.us", {} }, .{ "cc.va.us", {} }, .{ "lib.va.us", {} }, .{ "cc.vi.us", {} }, .{ "lib.vi.us", {} }, .{ "cc.vt.us", {} }, .{ "lib.vt.us", {} }, .{ "cc.wa.us", {} }, .{ "lib.wa.us", {} }, .{ "cc.wi.us", {} }, .{ "lib.wi.us", {} }, .{ "cc.wv.us", {} }, .{ "cc.wy.us", {} }, .{ "k12.wy.us", {} }, .{ "lib.wy.us", {} }, .{ "chtr.k12.ma.us", {} }, .{ "paroch.k12.ma.us", {} }, .{ "pvt.k12.ma.us", {} }, .{ "ann-arbor.mi.us", {} }, .{ "cog.mi.us", {} }, .{ "dst.mi.us", {} }, .{ "eaton.mi.us", {} }, .{ "gen.mi.us", {} }, .{ "mus.mi.us", {} }, .{ "tec.mi.us", {} }, .{ "washtenaw.mi.us", {} }, .{ "uy", {} }, .{ "com.uy", {} }, .{ "edu.uy", {} }, .{ "gub.uy", {} }, .{ "mil.uy", {} }, .{ "net.uy", {} }, .{ "org.uy", {} }, .{ "uz", {} }, .{ "co.uz", {} }, .{ "com.uz", {} }, .{ "net.uz", {} }, .{ "org.uz", {} }, .{ "va", {} }, .{ "vc", {} }, .{ "com.vc", {} }, .{ "edu.vc", {} }, .{ "gov.vc", {} }, .{ "mil.vc", {} }, .{ "net.vc", {} }, .{ "org.vc", {} }, .{ "ve", {} }, .{ "arts.ve", {} }, .{ "bib.ve", {} }, .{ "co.ve", {} }, .{ "com.ve", {} }, .{ "e12.ve", {} }, .{ "edu.ve", {} }, .{ "emprende.ve", {} }, .{ "firm.ve", {} }, .{ "gob.ve", {} }, .{ "gov.ve", {} }, .{ "ia.ve", {} }, .{ "info.ve", {} }, .{ "int.ve", {} }, .{ "mil.ve", {} }, .{ "net.ve", {} }, .{ "nom.ve", {} }, .{ "org.ve", {} }, .{ "rar.ve", {} }, .{ "rec.ve", {} }, .{ "store.ve", {} }, .{ "tec.ve", {} }, .{ "web.ve", {} }, .{ "vg", {} }, .{ "edu.vg", {} }, .{ "vi", {} }, .{ "co.vi", {} }, .{ "com.vi", {} }, .{ "k12.vi", {} }, .{ "net.vi", {} }, .{ "org.vi", {} }, .{ "vn", {} }, .{ "ac.vn", {} }, .{ "ai.vn", {} }, .{ "biz.vn", {} }, .{ "com.vn", {} }, .{ "edu.vn", {} }, .{ "gov.vn", {} }, .{ "health.vn", {} }, .{ "id.vn", {} }, .{ "info.vn", {} }, .{ "int.vn", {} }, .{ "io.vn", {} }, .{ "name.vn", {} }, .{ "net.vn", {} }, .{ "org.vn", {} }, .{ "pro.vn", {} }, .{ "angiang.vn", {} }, .{ "bacgiang.vn", {} }, .{ "backan.vn", {} }, .{ "baclieu.vn", {} }, .{ "bacninh.vn", {} }, .{ "baria-vungtau.vn", {} }, .{ "bentre.vn", {} }, .{ "binhdinh.vn", {} }, .{ "binhduong.vn", {} }, .{ "binhphuoc.vn", {} }, .{ "binhthuan.vn", {} }, .{ "camau.vn", {} }, .{ "cantho.vn", {} }, .{ "caobang.vn", {} }, .{ "daklak.vn", {} }, .{ "daknong.vn", {} }, .{ "danang.vn", {} }, .{ "dienbien.vn", {} }, .{ "dongnai.vn", {} }, .{ "dongthap.vn", {} }, .{ "gialai.vn", {} }, .{ "hagiang.vn", {} }, .{ "haiduong.vn", {} }, .{ "haiphong.vn", {} }, .{ "hanam.vn", {} }, .{ "hanoi.vn", {} }, .{ "hatinh.vn", {} }, .{ "haugiang.vn", {} }, .{ "hoabinh.vn", {} }, .{ "hungyen.vn", {} }, .{ "khanhhoa.vn", {} }, .{ "kiengiang.vn", {} }, .{ "kontum.vn", {} }, .{ "laichau.vn", {} }, .{ "lamdong.vn", {} }, .{ "langson.vn", {} }, .{ "laocai.vn", {} }, .{ "longan.vn", {} }, .{ "namdinh.vn", {} }, .{ "nghean.vn", {} }, .{ "ninhbinh.vn", {} }, .{ "ninhthuan.vn", {} }, .{ "phutho.vn", {} }, .{ "phuyen.vn", {} }, .{ "quangbinh.vn", {} }, .{ "quangnam.vn", {} }, .{ "quangngai.vn", {} }, .{ "quangninh.vn", {} }, .{ "quangtri.vn", {} }, .{ "soctrang.vn", {} }, .{ "sonla.vn", {} }, .{ "tayninh.vn", {} }, .{ "thaibinh.vn", {} }, .{ "thainguyen.vn", {} }, .{ "thanhhoa.vn", {} }, .{ "thanhphohochiminh.vn", {} }, .{ "thuathienhue.vn", {} }, .{ "tiengiang.vn", {} }, .{ "travinh.vn", {} }, .{ "tuyenquang.vn", {} }, .{ "vinhlong.vn", {} }, .{ "vinhphuc.vn", {} }, .{ "yenbai.vn", {} }, .{ "vu", {} }, .{ "com.vu", {} }, .{ "edu.vu", {} }, .{ "net.vu", {} }, .{ "org.vu", {} }, .{ "wf", {} }, .{ "ws", {} }, .{ "com.ws", {} }, .{ "edu.ws", {} }, .{ "gov.ws", {} }, .{ "net.ws", {} }, .{ "org.ws", {} }, .{ "yt", {} }, .{ "امارات", {} }, .{ "հայ", {} }, .{ "বাংলা", {} }, .{ "бг", {} }, .{ "البحرين", {} }, .{ "бел", {} }, .{ "中国", {} }, .{ "中國", {} }, .{ "الجزائر", {} }, .{ "مصر", {} }, .{ "ею", {} }, .{ "ευ", {} }, .{ "موريتانيا", {} }, .{ "გე", {} }, .{ "ελ", {} }, .{ "香港", {} }, .{ "個人.香港", {} }, .{ "公司.香港", {} }, .{ "政府.香港", {} }, .{ "教育.香港", {} }, .{ "組織.香港", {} }, .{ "網絡.香港", {} }, .{ "ಭಾರತ", {} }, .{ "ଭାରତ", {} }, .{ "ভাৰত", {} }, .{ "भारतम्", {} }, .{ "भारोत", {} }, .{ "ڀارت", {} }, .{ "ഭാരതം", {} }, .{ "भारत", {} }, .{ "بارت", {} }, .{ "بھارت", {} }, .{ "భారత్", {} }, .{ "ભારત", {} }, .{ "ਭਾਰਤ", {} }, .{ "ভারত", {} }, .{ "இந்தியா", {} }, .{ "ایران", {} }, .{ "ايران", {} }, .{ "عراق", {} }, .{ "الاردن", {} }, .{ "한국", {} }, .{ "қаз", {} }, .{ "ລາວ", {} }, .{ "ලංකා", {} }, .{ "இலங்கை", {} }, .{ "المغرب", {} }, .{ "мкд", {} }, .{ "мон", {} }, .{ "澳門", {} }, .{ "澳门", {} }, .{ "مليسيا", {} }, .{ "عمان", {} }, .{ "پاکستان", {} }, .{ "پاكستان", {} }, .{ "فلسطين", {} }, .{ "срб", {} }, .{ "ак.срб", {} }, .{ "обр.срб", {} }, .{ "од.срб", {} }, .{ "орг.срб", {} }, .{ "пр.срб", {} }, .{ "упр.срб", {} }, .{ "рф", {} }, .{ "قطر", {} }, .{ "السعودية", {} }, .{ "السعودیة", {} }, .{ "السعودیۃ", {} }, .{ "السعوديه", {} }, .{ "سودان", {} }, .{ "新加坡", {} }, .{ "சிங்கப்பூர்", {} }, .{ "سورية", {} }, .{ "سوريا", {} }, .{ "ไทย", {} }, .{ "ทหาร.ไทย", {} }, .{ "ธุรกิจ.ไทย", {} }, .{ "เน็ต.ไทย", {} }, .{ "รัฐบาล.ไทย", {} }, .{ "ศึกษา.ไทย", {} }, .{ "องค์กร.ไทย", {} }, .{ "تونس", {} }, .{ "台灣", {} }, .{ "台湾", {} }, .{ "臺灣", {} }, .{ "укр", {} }, .{ "اليمن", {} }, .{ "xxx", {} }, .{ "ye", {} }, .{ "com.ye", {} }, .{ "edu.ye", {} }, .{ "gov.ye", {} }, .{ "mil.ye", {} }, .{ "net.ye", {} }, .{ "org.ye", {} }, .{ "ac.za", {} }, .{ "agric.za", {} }, .{ "alt.za", {} }, .{ "co.za", {} }, .{ "edu.za", {} }, .{ "gov.za", {} }, .{ "grondar.za", {} }, .{ "law.za", {} }, .{ "mil.za", {} }, .{ "net.za", {} }, .{ "ngo.za", {} }, .{ "nic.za", {} }, .{ "nis.za", {} }, .{ "nom.za", {} }, .{ "org.za", {} }, .{ "school.za", {} }, .{ "tm.za", {} }, .{ "web.za", {} }, .{ "zm", {} }, .{ "ac.zm", {} }, .{ "biz.zm", {} }, .{ "co.zm", {} }, .{ "com.zm", {} }, .{ "edu.zm", {} }, .{ "gov.zm", {} }, .{ "info.zm", {} }, .{ "mil.zm", {} }, .{ "net.zm", {} }, .{ "org.zm", {} }, .{ "sch.zm", {} }, .{ "zw", {} }, .{ "ac.zw", {} }, .{ "co.zw", {} }, .{ "gov.zw", {} }, .{ "mil.zw", {} }, .{ "org.zw", {} }, .{ "aaa", {} }, .{ "aarp", {} }, .{ "abb", {} }, .{ "abbott", {} }, .{ "abbvie", {} }, .{ "abc", {} }, .{ "able", {} }, .{ "abogado", {} }, .{ "abudhabi", {} }, .{ "academy", {} }, .{ "accenture", {} }, .{ "accountant", {} }, .{ "accountants", {} }, .{ "aco", {} }, .{ "actor", {} }, .{ "ads", {} }, .{ "adult", {} }, .{ "aeg", {} }, .{ "aetna", {} }, .{ "afl", {} }, .{ "africa", {} }, .{ "agakhan", {} }, .{ "agency", {} }, .{ "aig", {} }, .{ "airbus", {} }, .{ "airforce", {} }, .{ "airtel", {} }, .{ "akdn", {} }, .{ "alibaba", {} }, .{ "alipay", {} }, .{ "allfinanz", {} }, .{ "allstate", {} }, .{ "ally", {} }, .{ "alsace", {} }, .{ "alstom", {} }, .{ "amazon", {} }, .{ "americanexpress", {} }, .{ "americanfamily", {} }, .{ "amex", {} }, .{ "amfam", {} }, .{ "amica", {} }, .{ "amsterdam", {} }, .{ "analytics", {} }, .{ "android", {} }, .{ "anquan", {} }, .{ "anz", {} }, .{ "aol", {} }, .{ "apartments", {} }, .{ "app", {} }, .{ "apple", {} }, .{ "aquarelle", {} }, .{ "arab", {} }, .{ "aramco", {} }, .{ "archi", {} }, .{ "army", {} }, .{ "art", {} }, .{ "arte", {} }, .{ "asda", {} }, .{ "associates", {} }, .{ "athleta", {} }, .{ "attorney", {} }, .{ "auction", {} }, .{ "audi", {} }, .{ "audible", {} }, .{ "audio", {} }, .{ "auspost", {} }, .{ "author", {} }, .{ "auto", {} }, .{ "autos", {} }, .{ "aws", {} }, .{ "axa", {} }, .{ "azure", {} }, .{ "baby", {} }, .{ "baidu", {} }, .{ "banamex", {} }, .{ "band", {} }, .{ "bank", {} }, .{ "bar", {} }, .{ "barcelona", {} }, .{ "barclaycard", {} }, .{ "barclays", {} }, .{ "barefoot", {} }, .{ "bargains", {} }, .{ "baseball", {} }, .{ "basketball", {} }, .{ "bauhaus", {} }, .{ "bayern", {} }, .{ "bbc", {} }, .{ "bbt", {} }, .{ "bbva", {} }, .{ "bcg", {} }, .{ "bcn", {} }, .{ "beats", {} }, .{ "beauty", {} }, .{ "beer", {} }, .{ "berlin", {} }, .{ "best", {} }, .{ "bestbuy", {} }, .{ "bet", {} }, .{ "bharti", {} }, .{ "bible", {} }, .{ "bid", {} }, .{ "bike", {} }, .{ "bing", {} }, .{ "bingo", {} }, .{ "bio", {} }, .{ "black", {} }, .{ "blackfriday", {} }, .{ "blockbuster", {} }, .{ "blog", {} }, .{ "bloomberg", {} }, .{ "blue", {} }, .{ "bms", {} }, .{ "bmw", {} }, .{ "bnpparibas", {} }, .{ "boats", {} }, .{ "boehringer", {} }, .{ "bofa", {} }, .{ "bom", {} }, .{ "bond", {} }, .{ "boo", {} }, .{ "book", {} }, .{ "booking", {} }, .{ "bosch", {} }, .{ "bostik", {} }, .{ "boston", {} }, .{ "bot", {} }, .{ "boutique", {} }, .{ "box", {} }, .{ "bradesco", {} }, .{ "bridgestone", {} }, .{ "broadway", {} }, .{ "broker", {} }, .{ "brother", {} }, .{ "brussels", {} }, .{ "build", {} }, .{ "builders", {} }, .{ "business", {} }, .{ "buy", {} }, .{ "buzz", {} }, .{ "bzh", {} }, .{ "cab", {} }, .{ "cafe", {} }, .{ "cal", {} }, .{ "call", {} }, .{ "calvinklein", {} }, .{ "cam", {} }, .{ "camera", {} }, .{ "camp", {} }, .{ "canon", {} }, .{ "capetown", {} }, .{ "capital", {} }, .{ "capitalone", {} }, .{ "car", {} }, .{ "caravan", {} }, .{ "cards", {} }, .{ "care", {} }, .{ "career", {} }, .{ "careers", {} }, .{ "cars", {} }, .{ "casa", {} }, .{ "case", {} }, .{ "cash", {} }, .{ "casino", {} }, .{ "catering", {} }, .{ "catholic", {} }, .{ "cba", {} }, .{ "cbn", {} }, .{ "cbre", {} }, .{ "center", {} }, .{ "ceo", {} }, .{ "cern", {} }, .{ "cfa", {} }, .{ "cfd", {} }, .{ "chanel", {} }, .{ "channel", {} }, .{ "charity", {} }, .{ "chase", {} }, .{ "chat", {} }, .{ "cheap", {} }, .{ "chintai", {} }, .{ "christmas", {} }, .{ "chrome", {} }, .{ "church", {} }, .{ "cipriani", {} }, .{ "circle", {} }, .{ "cisco", {} }, .{ "citadel", {} }, .{ "citi", {} }, .{ "citic", {} }, .{ "city", {} }, .{ "claims", {} }, .{ "cleaning", {} }, .{ "click", {} }, .{ "clinic", {} }, .{ "clinique", {} }, .{ "clothing", {} }, .{ "cloud", {} }, .{ "club", {} }, .{ "clubmed", {} }, .{ "coach", {} }, .{ "codes", {} }, .{ "coffee", {} }, .{ "college", {} }, .{ "cologne", {} }, .{ "commbank", {} }, .{ "community", {} }, .{ "company", {} }, .{ "compare", {} }, .{ "computer", {} }, .{ "comsec", {} }, .{ "condos", {} }, .{ "construction", {} }, .{ "consulting", {} }, .{ "contact", {} }, .{ "contractors", {} }, .{ "cooking", {} }, .{ "cool", {} }, .{ "corsica", {} }, .{ "country", {} }, .{ "coupon", {} }, .{ "coupons", {} }, .{ "courses", {} }, .{ "cpa", {} }, .{ "credit", {} }, .{ "creditcard", {} }, .{ "creditunion", {} }, .{ "cricket", {} }, .{ "crown", {} }, .{ "crs", {} }, .{ "cruise", {} }, .{ "cruises", {} }, .{ "cuisinella", {} }, .{ "cymru", {} }, .{ "cyou", {} }, .{ "dad", {} }, .{ "dance", {} }, .{ "data", {} }, .{ "date", {} }, .{ "dating", {} }, .{ "datsun", {} }, .{ "day", {} }, .{ "dclk", {} }, .{ "dds", {} }, .{ "deal", {} }, .{ "dealer", {} }, .{ "deals", {} }, .{ "degree", {} }, .{ "delivery", {} }, .{ "dell", {} }, .{ "deloitte", {} }, .{ "delta", {} }, .{ "democrat", {} }, .{ "dental", {} }, .{ "dentist", {} }, .{ "desi", {} }, .{ "design", {} }, .{ "dev", {} }, .{ "dhl", {} }, .{ "diamonds", {} }, .{ "diet", {} }, .{ "digital", {} }, .{ "direct", {} }, .{ "directory", {} }, .{ "discount", {} }, .{ "discover", {} }, .{ "dish", {} }, .{ "diy", {} }, .{ "dnp", {} }, .{ "docs", {} }, .{ "doctor", {} }, .{ "dog", {} }, .{ "domains", {} }, .{ "dot", {} }, .{ "download", {} }, .{ "drive", {} }, .{ "dtv", {} }, .{ "dubai", {} }, .{ "dupont", {} }, .{ "durban", {} }, .{ "dvag", {} }, .{ "dvr", {} }, .{ "earth", {} }, .{ "eat", {} }, .{ "eco", {} }, .{ "edeka", {} }, .{ "education", {} }, .{ "email", {} }, .{ "emerck", {} }, .{ "energy", {} }, .{ "engineer", {} }, .{ "engineering", {} }, .{ "enterprises", {} }, .{ "epson", {} }, .{ "equipment", {} }, .{ "ericsson", {} }, .{ "erni", {} }, .{ "esq", {} }, .{ "estate", {} }, .{ "eurovision", {} }, .{ "eus", {} }, .{ "events", {} }, .{ "exchange", {} }, .{ "expert", {} }, .{ "exposed", {} }, .{ "express", {} }, .{ "extraspace", {} }, .{ "fage", {} }, .{ "fail", {} }, .{ "fairwinds", {} }, .{ "faith", {} }, .{ "family", {} }, .{ "fan", {} }, .{ "fans", {} }, .{ "farm", {} }, .{ "farmers", {} }, .{ "fashion", {} }, .{ "fast", {} }, .{ "fedex", {} }, .{ "feedback", {} }, .{ "ferrari", {} }, .{ "ferrero", {} }, .{ "fidelity", {} }, .{ "fido", {} }, .{ "film", {} }, .{ "final", {} }, .{ "finance", {} }, .{ "financial", {} }, .{ "fire", {} }, .{ "firestone", {} }, .{ "firmdale", {} }, .{ "fish", {} }, .{ "fishing", {} }, .{ "fit", {} }, .{ "fitness", {} }, .{ "flickr", {} }, .{ "flights", {} }, .{ "flir", {} }, .{ "florist", {} }, .{ "flowers", {} }, .{ "fly", {} }, .{ "foo", {} }, .{ "food", {} }, .{ "football", {} }, .{ "ford", {} }, .{ "forex", {} }, .{ "forsale", {} }, .{ "forum", {} }, .{ "foundation", {} }, .{ "fox", {} }, .{ "free", {} }, .{ "fresenius", {} }, .{ "frl", {} }, .{ "frogans", {} }, .{ "frontier", {} }, .{ "ftr", {} }, .{ "fujitsu", {} }, .{ "fun", {} }, .{ "fund", {} }, .{ "furniture", {} }, .{ "futbol", {} }, .{ "fyi", {} }, .{ "gal", {} }, .{ "gallery", {} }, .{ "gallo", {} }, .{ "gallup", {} }, .{ "game", {} }, .{ "games", {} }, .{ "gap", {} }, .{ "garden", {} }, .{ "gay", {} }, .{ "gbiz", {} }, .{ "gdn", {} }, .{ "gea", {} }, .{ "gent", {} }, .{ "genting", {} }, .{ "george", {} }, .{ "ggee", {} }, .{ "gift", {} }, .{ "gifts", {} }, .{ "gives", {} }, .{ "giving", {} }, .{ "glass", {} }, .{ "gle", {} }, .{ "global", {} }, .{ "globo", {} }, .{ "gmail", {} }, .{ "gmbh", {} }, .{ "gmo", {} }, .{ "gmx", {} }, .{ "godaddy", {} }, .{ "gold", {} }, .{ "goldpoint", {} }, .{ "golf", {} }, .{ "goo", {} }, .{ "goodyear", {} }, .{ "goog", {} }, .{ "google", {} }, .{ "gop", {} }, .{ "got", {} }, .{ "grainger", {} }, .{ "graphics", {} }, .{ "gratis", {} }, .{ "green", {} }, .{ "gripe", {} }, .{ "grocery", {} }, .{ "group", {} }, .{ "gucci", {} }, .{ "guge", {} }, .{ "guide", {} }, .{ "guitars", {} }, .{ "guru", {} }, .{ "hair", {} }, .{ "hamburg", {} }, .{ "hangout", {} }, .{ "haus", {} }, .{ "hbo", {} }, .{ "hdfc", {} }, .{ "hdfcbank", {} }, .{ "health", {} }, .{ "healthcare", {} }, .{ "help", {} }, .{ "helsinki", {} }, .{ "here", {} }, .{ "hermes", {} }, .{ "hiphop", {} }, .{ "hisamitsu", {} }, .{ "hitachi", {} }, .{ "hiv", {} }, .{ "hkt", {} }, .{ "hockey", {} }, .{ "holdings", {} }, .{ "holiday", {} }, .{ "homedepot", {} }, .{ "homegoods", {} }, .{ "homes", {} }, .{ "homesense", {} }, .{ "honda", {} }, .{ "horse", {} }, .{ "hospital", {} }, .{ "host", {} }, .{ "hosting", {} }, .{ "hot", {} }, .{ "hotel", {} }, .{ "hotels", {} }, .{ "hotmail", {} }, .{ "house", {} }, .{ "how", {} }, .{ "hsbc", {} }, .{ "hughes", {} }, .{ "hyatt", {} }, .{ "hyundai", {} }, .{ "ibm", {} }, .{ "icbc", {} }, .{ "ice", {} }, .{ "icu", {} }, .{ "ieee", {} }, .{ "ifm", {} }, .{ "ikano", {} }, .{ "imamat", {} }, .{ "imdb", {} }, .{ "immo", {} }, .{ "immobilien", {} }, .{ "inc", {} }, .{ "industries", {} }, .{ "infiniti", {} }, .{ "ing", {} }, .{ "ink", {} }, .{ "institute", {} }, .{ "insurance", {} }, .{ "insure", {} }, .{ "international", {} }, .{ "intuit", {} }, .{ "investments", {} }, .{ "ipiranga", {} }, .{ "irish", {} }, .{ "ismaili", {} }, .{ "ist", {} }, .{ "istanbul", {} }, .{ "itau", {} }, .{ "itv", {} }, .{ "jaguar", {} }, .{ "java", {} }, .{ "jcb", {} }, .{ "jeep", {} }, .{ "jetzt", {} }, .{ "jewelry", {} }, .{ "jio", {} }, .{ "jll", {} }, .{ "jmp", {} }, .{ "jnj", {} }, .{ "joburg", {} }, .{ "jot", {} }, .{ "joy", {} }, .{ "jpmorgan", {} }, .{ "jprs", {} }, .{ "juegos", {} }, .{ "juniper", {} }, .{ "kaufen", {} }, .{ "kddi", {} }, .{ "kerryhotels", {} }, .{ "kerryproperties", {} }, .{ "kfh", {} }, .{ "kia", {} }, .{ "kids", {} }, .{ "kim", {} }, .{ "kindle", {} }, .{ "kitchen", {} }, .{ "kiwi", {} }, .{ "koeln", {} }, .{ "komatsu", {} }, .{ "kosher", {} }, .{ "kpmg", {} }, .{ "kpn", {} }, .{ "krd", {} }, .{ "kred", {} }, .{ "kuokgroup", {} }, .{ "kyoto", {} }, .{ "lacaixa", {} }, .{ "lamborghini", {} }, .{ "lamer", {} }, .{ "land", {} }, .{ "landrover", {} }, .{ "lanxess", {} }, .{ "lasalle", {} }, .{ "lat", {} }, .{ "latino", {} }, .{ "latrobe", {} }, .{ "law", {} }, .{ "lawyer", {} }, .{ "lds", {} }, .{ "lease", {} }, .{ "leclerc", {} }, .{ "lefrak", {} }, .{ "legal", {} }, .{ "lego", {} }, .{ "lexus", {} }, .{ "lgbt", {} }, .{ "lidl", {} }, .{ "life", {} }, .{ "lifeinsurance", {} }, .{ "lifestyle", {} }, .{ "lighting", {} }, .{ "like", {} }, .{ "lilly", {} }, .{ "limited", {} }, .{ "limo", {} }, .{ "lincoln", {} }, .{ "link", {} }, .{ "live", {} }, .{ "living", {} }, .{ "llc", {} }, .{ "llp", {} }, .{ "loan", {} }, .{ "loans", {} }, .{ "locker", {} }, .{ "locus", {} }, .{ "lol", {} }, .{ "london", {} }, .{ "lotte", {} }, .{ "lotto", {} }, .{ "love", {} }, .{ "lpl", {} }, .{ "lplfinancial", {} }, .{ "ltd", {} }, .{ "ltda", {} }, .{ "lundbeck", {} }, .{ "luxe", {} }, .{ "luxury", {} }, .{ "madrid", {} }, .{ "maif", {} }, .{ "maison", {} }, .{ "makeup", {} }, .{ "man", {} }, .{ "management", {} }, .{ "mango", {} }, .{ "map", {} }, .{ "market", {} }, .{ "marketing", {} }, .{ "markets", {} }, .{ "marriott", {} }, .{ "marshalls", {} }, .{ "mattel", {} }, .{ "mba", {} }, .{ "mckinsey", {} }, .{ "med", {} }, .{ "media", {} }, .{ "meet", {} }, .{ "melbourne", {} }, .{ "meme", {} }, .{ "memorial", {} }, .{ "men", {} }, .{ "menu", {} }, .{ "merck", {} }, .{ "merckmsd", {} }, .{ "miami", {} }, .{ "microsoft", {} }, .{ "mini", {} }, .{ "mint", {} }, .{ "mit", {} }, .{ "mitsubishi", {} }, .{ "mlb", {} }, .{ "mls", {} }, .{ "mma", {} }, .{ "mobile", {} }, .{ "moda", {} }, .{ "moe", {} }, .{ "moi", {} }, .{ "mom", {} }, .{ "monash", {} }, .{ "money", {} }, .{ "monster", {} }, .{ "mormon", {} }, .{ "mortgage", {} }, .{ "moscow", {} }, .{ "moto", {} }, .{ "motorcycles", {} }, .{ "mov", {} }, .{ "movie", {} }, .{ "msd", {} }, .{ "mtn", {} }, .{ "mtr", {} }, .{ "music", {} }, .{ "nab", {} }, .{ "nagoya", {} }, .{ "navy", {} }, .{ "nba", {} }, .{ "nec", {} }, .{ "netbank", {} }, .{ "netflix", {} }, .{ "network", {} }, .{ "neustar", {} }, .{ "new", {} }, .{ "news", {} }, .{ "next", {} }, .{ "nextdirect", {} }, .{ "nexus", {} }, .{ "nfl", {} }, .{ "ngo", {} }, .{ "nhk", {} }, .{ "nico", {} }, .{ "nike", {} }, .{ "nikon", {} }, .{ "ninja", {} }, .{ "nissan", {} }, .{ "nissay", {} }, .{ "nokia", {} }, .{ "norton", {} }, .{ "now", {} }, .{ "nowruz", {} }, .{ "nowtv", {} }, .{ "nra", {} }, .{ "nrw", {} }, .{ "ntt", {} }, .{ "nyc", {} }, .{ "obi", {} }, .{ "observer", {} }, .{ "office", {} }, .{ "okinawa", {} }, .{ "olayan", {} }, .{ "olayangroup", {} }, .{ "ollo", {} }, .{ "omega", {} }, .{ "one", {} }, .{ "ong", {} }, .{ "onl", {} }, .{ "online", {} }, .{ "ooo", {} }, .{ "open", {} }, .{ "oracle", {} }, .{ "orange", {} }, .{ "organic", {} }, .{ "origins", {} }, .{ "osaka", {} }, .{ "otsuka", {} }, .{ "ott", {} }, .{ "ovh", {} }, .{ "page", {} }, .{ "panasonic", {} }, .{ "paris", {} }, .{ "pars", {} }, .{ "partners", {} }, .{ "parts", {} }, .{ "party", {} }, .{ "pay", {} }, .{ "pccw", {} }, .{ "pet", {} }, .{ "pfizer", {} }, .{ "pharmacy", {} }, .{ "phd", {} }, .{ "philips", {} }, .{ "phone", {} }, .{ "photo", {} }, .{ "photography", {} }, .{ "photos", {} }, .{ "physio", {} }, .{ "pics", {} }, .{ "pictet", {} }, .{ "pictures", {} }, .{ "pid", {} }, .{ "pin", {} }, .{ "ping", {} }, .{ "pink", {} }, .{ "pioneer", {} }, .{ "pizza", {} }, .{ "place", {} }, .{ "play", {} }, .{ "playstation", {} }, .{ "plumbing", {} }, .{ "plus", {} }, .{ "pnc", {} }, .{ "pohl", {} }, .{ "poker", {} }, .{ "politie", {} }, .{ "porn", {} }, .{ "praxi", {} }, .{ "press", {} }, .{ "prime", {} }, .{ "prod", {} }, .{ "productions", {} }, .{ "prof", {} }, .{ "progressive", {} }, .{ "promo", {} }, .{ "properties", {} }, .{ "property", {} }, .{ "protection", {} }, .{ "pru", {} }, .{ "prudential", {} }, .{ "pub", {} }, .{ "pwc", {} }, .{ "qpon", {} }, .{ "quebec", {} }, .{ "quest", {} }, .{ "racing", {} }, .{ "radio", {} }, .{ "read", {} }, .{ "realestate", {} }, .{ "realtor", {} }, .{ "realty", {} }, .{ "recipes", {} }, .{ "red", {} }, .{ "redumbrella", {} }, .{ "rehab", {} }, .{ "reise", {} }, .{ "reisen", {} }, .{ "reit", {} }, .{ "reliance", {} }, .{ "ren", {} }, .{ "rent", {} }, .{ "rentals", {} }, .{ "repair", {} }, .{ "report", {} }, .{ "republican", {} }, .{ "rest", {} }, .{ "restaurant", {} }, .{ "review", {} }, .{ "reviews", {} }, .{ "rexroth", {} }, .{ "rich", {} }, .{ "richardli", {} }, .{ "ricoh", {} }, .{ "ril", {} }, .{ "rio", {} }, .{ "rip", {} }, .{ "rocks", {} }, .{ "rodeo", {} }, .{ "rogers", {} }, .{ "room", {} }, .{ "rsvp", {} }, .{ "rugby", {} }, .{ "ruhr", {} }, .{ "run", {} }, .{ "rwe", {} }, .{ "ryukyu", {} }, .{ "saarland", {} }, .{ "safe", {} }, .{ "safety", {} }, .{ "sakura", {} }, .{ "sale", {} }, .{ "salon", {} }, .{ "samsclub", {} }, .{ "samsung", {} }, .{ "sandvik", {} }, .{ "sandvikcoromant", {} }, .{ "sanofi", {} }, .{ "sap", {} }, .{ "sarl", {} }, .{ "sas", {} }, .{ "save", {} }, .{ "saxo", {} }, .{ "sbi", {} }, .{ "sbs", {} }, .{ "scb", {} }, .{ "schaeffler", {} }, .{ "schmidt", {} }, .{ "scholarships", {} }, .{ "school", {} }, .{ "schule", {} }, .{ "schwarz", {} }, .{ "science", {} }, .{ "scot", {} }, .{ "search", {} }, .{ "seat", {} }, .{ "secure", {} }, .{ "security", {} }, .{ "seek", {} }, .{ "select", {} }, .{ "sener", {} }, .{ "services", {} }, .{ "seven", {} }, .{ "sew", {} }, .{ "sex", {} }, .{ "sexy", {} }, .{ "sfr", {} }, .{ "shangrila", {} }, .{ "sharp", {} }, .{ "shell", {} }, .{ "shia", {} }, .{ "shiksha", {} }, .{ "shoes", {} }, .{ "shop", {} }, .{ "shopping", {} }, .{ "shouji", {} }, .{ "show", {} }, .{ "silk", {} }, .{ "sina", {} }, .{ "singles", {} }, .{ "site", {} }, .{ "ski", {} }, .{ "skin", {} }, .{ "sky", {} }, .{ "skype", {} }, .{ "sling", {} }, .{ "smart", {} }, .{ "smile", {} }, .{ "sncf", {} }, .{ "soccer", {} }, .{ "social", {} }, .{ "softbank", {} }, .{ "software", {} }, .{ "sohu", {} }, .{ "solar", {} }, .{ "solutions", {} }, .{ "song", {} }, .{ "sony", {} }, .{ "soy", {} }, .{ "spa", {} }, .{ "space", {} }, .{ "sport", {} }, .{ "spot", {} }, .{ "srl", {} }, .{ "stada", {} }, .{ "staples", {} }, .{ "star", {} }, .{ "statebank", {} }, .{ "statefarm", {} }, .{ "stc", {} }, .{ "stcgroup", {} }, .{ "stockholm", {} }, .{ "storage", {} }, .{ "store", {} }, .{ "stream", {} }, .{ "studio", {} }, .{ "study", {} }, .{ "style", {} }, .{ "sucks", {} }, .{ "supplies", {} }, .{ "supply", {} }, .{ "support", {} }, .{ "surf", {} }, .{ "surgery", {} }, .{ "suzuki", {} }, .{ "swatch", {} }, .{ "swiss", {} }, .{ "sydney", {} }, .{ "systems", {} }, .{ "tab", {} }, .{ "taipei", {} }, .{ "talk", {} }, .{ "taobao", {} }, .{ "target", {} }, .{ "tatamotors", {} }, .{ "tatar", {} }, .{ "tattoo", {} }, .{ "tax", {} }, .{ "taxi", {} }, .{ "tci", {} }, .{ "tdk", {} }, .{ "team", {} }, .{ "tech", {} }, .{ "technology", {} }, .{ "temasek", {} }, .{ "tennis", {} }, .{ "teva", {} }, .{ "thd", {} }, .{ "theater", {} }, .{ "theatre", {} }, .{ "tiaa", {} }, .{ "tickets", {} }, .{ "tienda", {} }, .{ "tips", {} }, .{ "tires", {} }, .{ "tirol", {} }, .{ "tjmaxx", {} }, .{ "tjx", {} }, .{ "tkmaxx", {} }, .{ "tmall", {} }, .{ "today", {} }, .{ "tokyo", {} }, .{ "tools", {} }, .{ "top", {} }, .{ "toray", {} }, .{ "toshiba", {} }, .{ "total", {} }, .{ "tours", {} }, .{ "town", {} }, .{ "toyota", {} }, .{ "toys", {} }, .{ "trade", {} }, .{ "trading", {} }, .{ "training", {} }, .{ "travel", {} }, .{ "travelers", {} }, .{ "travelersinsurance", {} }, .{ "trust", {} }, .{ "trv", {} }, .{ "tube", {} }, .{ "tui", {} }, .{ "tunes", {} }, .{ "tushu", {} }, .{ "tvs", {} }, .{ "ubank", {} }, .{ "ubs", {} }, .{ "unicom", {} }, .{ "university", {} }, .{ "uno", {} }, .{ "uol", {} }, .{ "ups", {} }, .{ "vacations", {} }, .{ "vana", {} }, .{ "vanguard", {} }, .{ "vegas", {} }, .{ "ventures", {} }, .{ "verisign", {} }, .{ "versicherung", {} }, .{ "vet", {} }, .{ "viajes", {} }, .{ "video", {} }, .{ "vig", {} }, .{ "viking", {} }, .{ "villas", {} }, .{ "vin", {} }, .{ "vip", {} }, .{ "virgin", {} }, .{ "visa", {} }, .{ "vision", {} }, .{ "viva", {} }, .{ "vivo", {} }, .{ "vlaanderen", {} }, .{ "vodka", {} }, .{ "volvo", {} }, .{ "vote", {} }, .{ "voting", {} }, .{ "voto", {} }, .{ "voyage", {} }, .{ "wales", {} }, .{ "walmart", {} }, .{ "walter", {} }, .{ "wang", {} }, .{ "wanggou", {} }, .{ "watch", {} }, .{ "watches", {} }, .{ "weather", {} }, .{ "weatherchannel", {} }, .{ "webcam", {} }, .{ "weber", {} }, .{ "website", {} }, .{ "wed", {} }, .{ "wedding", {} }, .{ "weibo", {} }, .{ "weir", {} }, .{ "whoswho", {} }, .{ "wien", {} }, .{ "wiki", {} }, .{ "williamhill", {} }, .{ "win", {} }, .{ "windows", {} }, .{ "wine", {} }, .{ "winners", {} }, .{ "wme", {} }, .{ "wolterskluwer", {} }, .{ "woodside", {} }, .{ "work", {} }, .{ "works", {} }, .{ "world", {} }, .{ "wow", {} }, .{ "wtc", {} }, .{ "wtf", {} }, .{ "xbox", {} }, .{ "xerox", {} }, .{ "xihuan", {} }, .{ "xin", {} }, .{ "कॉम", {} }, .{ "セール", {} }, .{ "佛山", {} }, .{ "慈善", {} }, .{ "集团", {} }, .{ "在线", {} }, .{ "点看", {} }, .{ "คอม", {} }, .{ "八卦", {} }, .{ "موقع", {} }, .{ "公益", {} }, .{ "公司", {} }, .{ "香格里拉", {} }, .{ "网站", {} }, .{ "移动", {} }, .{ "我爱你", {} }, .{ "москва", {} }, .{ "католик", {} }, .{ "онлайн", {} }, .{ "сайт", {} }, .{ "联通", {} }, .{ "קום", {} }, .{ "时尚", {} }, .{ "微博", {} }, .{ "淡马锡", {} }, .{ "ファッション", {} }, .{ "орг", {} }, .{ "नेट", {} }, .{ "ストア", {} }, .{ "アマゾン", {} }, .{ "삼성", {} }, .{ "商标", {} }, .{ "商店", {} }, .{ "商城", {} }, .{ "дети", {} }, .{ "ポイント", {} }, .{ "新闻", {} }, .{ "家電", {} }, .{ "كوم", {} }, .{ "中文网", {} }, .{ "中信", {} }, .{ "娱乐", {} }, .{ "谷歌", {} }, .{ "電訊盈科", {} }, .{ "购物", {} }, .{ "クラウド", {} }, .{ "通販", {} }, .{ "网店", {} }, .{ "संगठन", {} }, .{ "餐厅", {} }, .{ "网络", {} }, .{ "ком", {} }, .{ "亚马逊", {} }, .{ "食品", {} }, .{ "飞利浦", {} }, .{ "手机", {} }, .{ "ارامكو", {} }, .{ "العليان", {} }, .{ "بازار", {} }, .{ "ابوظبي", {} }, .{ "كاثوليك", {} }, .{ "همراه", {} }, .{ "닷컴", {} }, .{ "政府", {} }, .{ "شبكة", {} }, .{ "بيتك", {} }, .{ "عرب", {} }, .{ "机构", {} }, .{ "组织机构", {} }, .{ "健康", {} }, .{ "招聘", {} }, .{ "рус", {} }, .{ "大拿", {} }, .{ "みんな", {} }, .{ "グーグル", {} }, .{ "世界", {} }, .{ "書籍", {} }, .{ "网址", {} }, .{ "닷넷", {} }, .{ "コム", {} }, .{ "天主教", {} }, .{ "游戏", {} }, .{ "vermögensberater", {} }, .{ "vermögensberatung", {} }, .{ "企业", {} }, .{ "信息", {} }, .{ "嘉里大酒店", {} }, .{ "嘉里", {} }, .{ "广东", {} }, .{ "政务", {} }, .{ "xyz", {} }, .{ "yachts", {} }, .{ "yahoo", {} }, .{ "yamaxun", {} }, .{ "yandex", {} }, .{ "yodobashi", {} }, .{ "yoga", {} }, .{ "yokohama", {} }, .{ "you", {} }, .{ "youtube", {} }, .{ "yun", {} }, .{ "zappos", {} }, .{ "zara", {} }, .{ "zero", {} }, .{ "zip", {} }, .{ "zone", {} }, .{ "zuerich", {} }, .{ "co.krd", {} }, .{ "edu.krd", {} }, .{ "art.pl", {} }, .{ "gliwice.pl", {} }, .{ "krakow.pl", {} }, .{ "poznan.pl", {} }, .{ "wroc.pl", {} }, .{ "zakopane.pl", {} }, .{ "12chars.dev", {} }, .{ "12chars.it", {} }, .{ "12chars.pro", {} }, .{ "cc.ua", {} }, .{ "inf.ua", {} }, .{ "ltd.ua", {} }, .{ "611.to", {} }, .{ "a2hosted.com", {} }, .{ "cpserver.com", {} }, .{ "*.on-acorn.io", {} }, .{ "activetrail.biz", {} }, .{ "adaptable.app", {} }, .{ "myaddr.dev", {} }, .{ "myaddr.io", {} }, .{ "dyn.addr.tools", {} }, .{ "myaddr.tools", {} }, .{ "adobeaemcloud.com", {} }, .{ "*.dev.adobeaemcloud.com", {} }, .{ "aem.live", {} }, .{ "hlx.live", {} }, .{ "adobeaemcloud.net", {} }, .{ "aem.network", {} }, .{ "aem.page", {} }, .{ "hlx.page", {} }, .{ "aem.reviews", {} }, .{ "adobeio-static.net", {} }, .{ "adobeioruntime.net", {} }, .{ "africa.com", {} }, .{ "*.auiusercontent.com", {} }, .{ "beep.pl", {} }, .{ "aiven.app", {} }, .{ "aivencloud.com", {} }, .{ "akadns.net", {} }, .{ "akamai.net", {} }, .{ "akamai-staging.net", {} }, .{ "akamaiedge.net", {} }, .{ "akamaiedge-staging.net", {} }, .{ "akamaihd.net", {} }, .{ "akamaihd-staging.net", {} }, .{ "akamaiorigin.net", {} }, .{ "akamaiorigin-staging.net", {} }, .{ "akamaized.net", {} }, .{ "akamaized-staging.net", {} }, .{ "edgekey.net", {} }, .{ "edgekey-staging.net", {} }, .{ "edgesuite.net", {} }, .{ "edgesuite-staging.net", {} }, .{ "barsy.ca", {} }, .{ "*.compute.estate", {} }, .{ "*.alces.network", {} }, .{ "alibabacloudcs.com", {} }, .{ "kasserver.com", {} }, .{ "altervista.org", {} }, .{ "alwaysdata.net", {} }, .{ "myamaze.net", {} }, .{ "execute-api.cn-north-1.amazonaws.com.cn", {} }, .{ "execute-api.cn-northwest-1.amazonaws.com.cn", {} }, .{ "execute-api.af-south-1.amazonaws.com", {} }, .{ "execute-api.ap-east-1.amazonaws.com", {} }, .{ "execute-api.ap-northeast-1.amazonaws.com", {} }, .{ "execute-api.ap-northeast-2.amazonaws.com", {} }, .{ "execute-api.ap-northeast-3.amazonaws.com", {} }, .{ "execute-api.ap-south-1.amazonaws.com", {} }, .{ "execute-api.ap-south-2.amazonaws.com", {} }, .{ "execute-api.ap-southeast-1.amazonaws.com", {} }, .{ "execute-api.ap-southeast-2.amazonaws.com", {} }, .{ "execute-api.ap-southeast-3.amazonaws.com", {} }, .{ "execute-api.ap-southeast-4.amazonaws.com", {} }, .{ "execute-api.ap-southeast-5.amazonaws.com", {} }, .{ "execute-api.ca-central-1.amazonaws.com", {} }, .{ "execute-api.ca-west-1.amazonaws.com", {} }, .{ "execute-api.eu-central-1.amazonaws.com", {} }, .{ "execute-api.eu-central-2.amazonaws.com", {} }, .{ "execute-api.eu-north-1.amazonaws.com", {} }, .{ "execute-api.eu-south-1.amazonaws.com", {} }, .{ "execute-api.eu-south-2.amazonaws.com", {} }, .{ "execute-api.eu-west-1.amazonaws.com", {} }, .{ "execute-api.eu-west-2.amazonaws.com", {} }, .{ "execute-api.eu-west-3.amazonaws.com", {} }, .{ "execute-api.il-central-1.amazonaws.com", {} }, .{ "execute-api.me-central-1.amazonaws.com", {} }, .{ "execute-api.me-south-1.amazonaws.com", {} }, .{ "execute-api.sa-east-1.amazonaws.com", {} }, .{ "execute-api.us-east-1.amazonaws.com", {} }, .{ "execute-api.us-east-2.amazonaws.com", {} }, .{ "execute-api.us-gov-east-1.amazonaws.com", {} }, .{ "execute-api.us-gov-west-1.amazonaws.com", {} }, .{ "execute-api.us-west-1.amazonaws.com", {} }, .{ "execute-api.us-west-2.amazonaws.com", {} }, .{ "cloudfront.net", {} }, .{ "auth.af-south-1.amazoncognito.com", {} }, .{ "auth.ap-east-1.amazoncognito.com", {} }, .{ "auth.ap-northeast-1.amazoncognito.com", {} }, .{ "auth.ap-northeast-2.amazoncognito.com", {} }, .{ "auth.ap-northeast-3.amazoncognito.com", {} }, .{ "auth.ap-south-1.amazoncognito.com", {} }, .{ "auth.ap-south-2.amazoncognito.com", {} }, .{ "auth.ap-southeast-1.amazoncognito.com", {} }, .{ "auth.ap-southeast-2.amazoncognito.com", {} }, .{ "auth.ap-southeast-3.amazoncognito.com", {} }, .{ "auth.ap-southeast-4.amazoncognito.com", {} }, .{ "auth.ap-southeast-5.amazoncognito.com", {} }, .{ "auth.ap-southeast-7.amazoncognito.com", {} }, .{ "auth.ca-central-1.amazoncognito.com", {} }, .{ "auth.ca-west-1.amazoncognito.com", {} }, .{ "auth.eu-central-1.amazoncognito.com", {} }, .{ "auth.eu-central-2.amazoncognito.com", {} }, .{ "auth.eu-north-1.amazoncognito.com", {} }, .{ "auth.eu-south-1.amazoncognito.com", {} }, .{ "auth.eu-south-2.amazoncognito.com", {} }, .{ "auth.eu-west-1.amazoncognito.com", {} }, .{ "auth.eu-west-2.amazoncognito.com", {} }, .{ "auth.eu-west-3.amazoncognito.com", {} }, .{ "auth.il-central-1.amazoncognito.com", {} }, .{ "auth.me-central-1.amazoncognito.com", {} }, .{ "auth.me-south-1.amazoncognito.com", {} }, .{ "auth.mx-central-1.amazoncognito.com", {} }, .{ "auth.sa-east-1.amazoncognito.com", {} }, .{ "auth.us-east-1.amazoncognito.com", {} }, .{ "auth-fips.us-east-1.amazoncognito.com", {} }, .{ "auth.us-east-2.amazoncognito.com", {} }, .{ "auth-fips.us-east-2.amazoncognito.com", {} }, .{ "auth-fips.us-gov-east-1.amazoncognito.com", {} }, .{ "auth-fips.us-gov-west-1.amazoncognito.com", {} }, .{ "auth.us-west-1.amazoncognito.com", {} }, .{ "auth-fips.us-west-1.amazoncognito.com", {} }, .{ "auth.us-west-2.amazoncognito.com", {} }, .{ "auth-fips.us-west-2.amazoncognito.com", {} }, .{ "auth.cognito-idp.eusc-de-east-1.on.amazonwebservices.eu", {} }, .{ "*.compute.amazonaws.com.cn", {} }, .{ "*.compute.amazonaws.com", {} }, .{ "*.compute-1.amazonaws.com", {} }, .{ "us-east-1.amazonaws.com", {} }, .{ "emrappui-prod.cn-north-1.amazonaws.com.cn", {} }, .{ "emrnotebooks-prod.cn-north-1.amazonaws.com.cn", {} }, .{ "emrstudio-prod.cn-north-1.amazonaws.com.cn", {} }, .{ "emrappui-prod.cn-northwest-1.amazonaws.com.cn", {} }, .{ "emrnotebooks-prod.cn-northwest-1.amazonaws.com.cn", {} }, .{ "emrstudio-prod.cn-northwest-1.amazonaws.com.cn", {} }, .{ "emrappui-prod.af-south-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.af-south-1.amazonaws.com", {} }, .{ "emrstudio-prod.af-south-1.amazonaws.com", {} }, .{ "emrappui-prod.ap-east-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-east-1.amazonaws.com", {} }, .{ "emrstudio-prod.ap-east-1.amazonaws.com", {} }, .{ "emrappui-prod.ap-northeast-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-northeast-1.amazonaws.com", {} }, .{ "emrstudio-prod.ap-northeast-1.amazonaws.com", {} }, .{ "emrappui-prod.ap-northeast-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-northeast-2.amazonaws.com", {} }, .{ "emrstudio-prod.ap-northeast-2.amazonaws.com", {} }, .{ "emrappui-prod.ap-northeast-3.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-northeast-3.amazonaws.com", {} }, .{ "emrstudio-prod.ap-northeast-3.amazonaws.com", {} }, .{ "emrappui-prod.ap-south-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-south-1.amazonaws.com", {} }, .{ "emrstudio-prod.ap-south-1.amazonaws.com", {} }, .{ "emrappui-prod.ap-south-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-south-2.amazonaws.com", {} }, .{ "emrstudio-prod.ap-south-2.amazonaws.com", {} }, .{ "emrappui-prod.ap-southeast-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-southeast-1.amazonaws.com", {} }, .{ "emrstudio-prod.ap-southeast-1.amazonaws.com", {} }, .{ "emrappui-prod.ap-southeast-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-southeast-2.amazonaws.com", {} }, .{ "emrstudio-prod.ap-southeast-2.amazonaws.com", {} }, .{ "emrappui-prod.ap-southeast-3.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-southeast-3.amazonaws.com", {} }, .{ "emrstudio-prod.ap-southeast-3.amazonaws.com", {} }, .{ "emrappui-prod.ap-southeast-4.amazonaws.com", {} }, .{ "emrnotebooks-prod.ap-southeast-4.amazonaws.com", {} }, .{ "emrstudio-prod.ap-southeast-4.amazonaws.com", {} }, .{ "emrappui-prod.ca-central-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.ca-central-1.amazonaws.com", {} }, .{ "emrstudio-prod.ca-central-1.amazonaws.com", {} }, .{ "emrappui-prod.ca-west-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.ca-west-1.amazonaws.com", {} }, .{ "emrstudio-prod.ca-west-1.amazonaws.com", {} }, .{ "emrappui-prod.eu-central-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-central-1.amazonaws.com", {} }, .{ "emrstudio-prod.eu-central-1.amazonaws.com", {} }, .{ "emrappui-prod.eu-central-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-central-2.amazonaws.com", {} }, .{ "emrstudio-prod.eu-central-2.amazonaws.com", {} }, .{ "emrappui-prod.eu-north-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-north-1.amazonaws.com", {} }, .{ "emrstudio-prod.eu-north-1.amazonaws.com", {} }, .{ "emrappui-prod.eu-south-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-south-1.amazonaws.com", {} }, .{ "emrstudio-prod.eu-south-1.amazonaws.com", {} }, .{ "emrappui-prod.eu-south-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-south-2.amazonaws.com", {} }, .{ "emrstudio-prod.eu-south-2.amazonaws.com", {} }, .{ "emrappui-prod.eu-west-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-west-1.amazonaws.com", {} }, .{ "emrstudio-prod.eu-west-1.amazonaws.com", {} }, .{ "emrappui-prod.eu-west-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-west-2.amazonaws.com", {} }, .{ "emrstudio-prod.eu-west-2.amazonaws.com", {} }, .{ "emrappui-prod.eu-west-3.amazonaws.com", {} }, .{ "emrnotebooks-prod.eu-west-3.amazonaws.com", {} }, .{ "emrstudio-prod.eu-west-3.amazonaws.com", {} }, .{ "emrappui-prod.il-central-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.il-central-1.amazonaws.com", {} }, .{ "emrstudio-prod.il-central-1.amazonaws.com", {} }, .{ "emrappui-prod.me-central-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.me-central-1.amazonaws.com", {} }, .{ "emrstudio-prod.me-central-1.amazonaws.com", {} }, .{ "emrappui-prod.me-south-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.me-south-1.amazonaws.com", {} }, .{ "emrstudio-prod.me-south-1.amazonaws.com", {} }, .{ "emrappui-prod.sa-east-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.sa-east-1.amazonaws.com", {} }, .{ "emrstudio-prod.sa-east-1.amazonaws.com", {} }, .{ "emrappui-prod.us-east-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.us-east-1.amazonaws.com", {} }, .{ "emrstudio-prod.us-east-1.amazonaws.com", {} }, .{ "emrappui-prod.us-east-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.us-east-2.amazonaws.com", {} }, .{ "emrstudio-prod.us-east-2.amazonaws.com", {} }, .{ "emrappui-prod.us-gov-east-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.us-gov-east-1.amazonaws.com", {} }, .{ "emrstudio-prod.us-gov-east-1.amazonaws.com", {} }, .{ "emrappui-prod.us-gov-west-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.us-gov-west-1.amazonaws.com", {} }, .{ "emrstudio-prod.us-gov-west-1.amazonaws.com", {} }, .{ "emrappui-prod.us-west-1.amazonaws.com", {} }, .{ "emrnotebooks-prod.us-west-1.amazonaws.com", {} }, .{ "emrstudio-prod.us-west-1.amazonaws.com", {} }, .{ "emrappui-prod.us-west-2.amazonaws.com", {} }, .{ "emrnotebooks-prod.us-west-2.amazonaws.com", {} }, .{ "emrstudio-prod.us-west-2.amazonaws.com", {} }, .{ "*.airflow.af-south-1.on.aws", {} }, .{ "*.airflow.ap-east-1.on.aws", {} }, .{ "*.airflow.ap-northeast-1.on.aws", {} }, .{ "*.airflow.ap-northeast-2.on.aws", {} }, .{ "*.airflow.ap-northeast-3.on.aws", {} }, .{ "*.airflow.ap-south-1.on.aws", {} }, .{ "*.airflow.ap-south-2.on.aws", {} }, .{ "*.airflow.ap-southeast-1.on.aws", {} }, .{ "*.airflow.ap-southeast-2.on.aws", {} }, .{ "*.airflow.ap-southeast-3.on.aws", {} }, .{ "*.airflow.ap-southeast-4.on.aws", {} }, .{ "*.airflow.ap-southeast-5.on.aws", {} }, .{ "*.airflow.ca-central-1.on.aws", {} }, .{ "*.airflow.ca-west-1.on.aws", {} }, .{ "*.airflow.eu-central-1.on.aws", {} }, .{ "*.airflow.eu-central-2.on.aws", {} }, .{ "*.airflow.eu-north-1.on.aws", {} }, .{ "*.airflow.eu-south-1.on.aws", {} }, .{ "*.airflow.eu-south-2.on.aws", {} }, .{ "*.airflow.eu-west-1.on.aws", {} }, .{ "*.airflow.eu-west-2.on.aws", {} }, .{ "*.airflow.eu-west-3.on.aws", {} }, .{ "*.airflow.il-central-1.on.aws", {} }, .{ "*.airflow.me-central-1.on.aws", {} }, .{ "*.airflow.me-south-1.on.aws", {} }, .{ "*.airflow.sa-east-1.on.aws", {} }, .{ "*.airflow.us-east-1.on.aws", {} }, .{ "*.airflow.us-east-2.on.aws", {} }, .{ "*.airflow.us-west-1.on.aws", {} }, .{ "*.airflow.us-west-2.on.aws", {} }, .{ "*.cn-north-1.airflow.amazonaws.com.cn", {} }, .{ "*.cn-northwest-1.airflow.amazonaws.com.cn", {} }, .{ "*.airflow.cn-north-1.on.amazonwebservices.com.cn", {} }, .{ "*.airflow.cn-northwest-1.on.amazonwebservices.com.cn", {} }, .{ "*.af-south-1.airflow.amazonaws.com", {} }, .{ "*.ap-east-1.airflow.amazonaws.com", {} }, .{ "*.ap-northeast-1.airflow.amazonaws.com", {} }, .{ "*.ap-northeast-2.airflow.amazonaws.com", {} }, .{ "*.ap-northeast-3.airflow.amazonaws.com", {} }, .{ "*.ap-south-1.airflow.amazonaws.com", {} }, .{ "*.ap-south-2.airflow.amazonaws.com", {} }, .{ "*.ap-southeast-1.airflow.amazonaws.com", {} }, .{ "*.ap-southeast-2.airflow.amazonaws.com", {} }, .{ "*.ap-southeast-3.airflow.amazonaws.com", {} }, .{ "*.ap-southeast-4.airflow.amazonaws.com", {} }, .{ "*.ap-southeast-5.airflow.amazonaws.com", {} }, .{ "*.ap-southeast-7.airflow.amazonaws.com", {} }, .{ "*.ca-central-1.airflow.amazonaws.com", {} }, .{ "*.ca-west-1.airflow.amazonaws.com", {} }, .{ "*.eu-central-1.airflow.amazonaws.com", {} }, .{ "*.eu-central-2.airflow.amazonaws.com", {} }, .{ "*.eu-north-1.airflow.amazonaws.com", {} }, .{ "*.eu-south-1.airflow.amazonaws.com", {} }, .{ "*.eu-south-2.airflow.amazonaws.com", {} }, .{ "*.eu-west-1.airflow.amazonaws.com", {} }, .{ "*.eu-west-2.airflow.amazonaws.com", {} }, .{ "*.eu-west-3.airflow.amazonaws.com", {} }, .{ "*.il-central-1.airflow.amazonaws.com", {} }, .{ "*.me-central-1.airflow.amazonaws.com", {} }, .{ "*.me-south-1.airflow.amazonaws.com", {} }, .{ "*.sa-east-1.airflow.amazonaws.com", {} }, .{ "*.us-east-1.airflow.amazonaws.com", {} }, .{ "*.us-east-2.airflow.amazonaws.com", {} }, .{ "*.us-west-1.airflow.amazonaws.com", {} }, .{ "*.us-west-2.airflow.amazonaws.com", {} }, .{ "*.rds.cn-north-1.amazonaws.com.cn", {} }, .{ "*.rds.cn-northwest-1.amazonaws.com.cn", {} }, .{ "*.af-south-1.rds.amazonaws.com", {} }, .{ "*.ap-east-1.rds.amazonaws.com", {} }, .{ "*.ap-east-2.rds.amazonaws.com", {} }, .{ "*.ap-northeast-1.rds.amazonaws.com", {} }, .{ "*.ap-northeast-2.rds.amazonaws.com", {} }, .{ "*.ap-northeast-3.rds.amazonaws.com", {} }, .{ "*.ap-south-1.rds.amazonaws.com", {} }, .{ "*.ap-south-2.rds.amazonaws.com", {} }, .{ "*.ap-southeast-1.rds.amazonaws.com", {} }, .{ "*.ap-southeast-2.rds.amazonaws.com", {} }, .{ "*.ap-southeast-3.rds.amazonaws.com", {} }, .{ "*.ap-southeast-4.rds.amazonaws.com", {} }, .{ "*.ap-southeast-5.rds.amazonaws.com", {} }, .{ "*.ap-southeast-6.rds.amazonaws.com", {} }, .{ "*.ap-southeast-7.rds.amazonaws.com", {} }, .{ "*.ca-central-1.rds.amazonaws.com", {} }, .{ "*.ca-west-1.rds.amazonaws.com", {} }, .{ "*.eu-central-1.rds.amazonaws.com", {} }, .{ "*.eu-central-2.rds.amazonaws.com", {} }, .{ "*.eu-west-1.rds.amazonaws.com", {} }, .{ "*.eu-west-2.rds.amazonaws.com", {} }, .{ "*.eu-west-3.rds.amazonaws.com", {} }, .{ "*.il-central-1.rds.amazonaws.com", {} }, .{ "*.me-central-1.rds.amazonaws.com", {} }, .{ "*.me-south-1.rds.amazonaws.com", {} }, .{ "*.mx-central-1.rds.amazonaws.com", {} }, .{ "*.sa-east-1.rds.amazonaws.com", {} }, .{ "*.us-east-1.rds.amazonaws.com", {} }, .{ "*.us-east-2.rds.amazonaws.com", {} }, .{ "*.us-gov-east-1.rds.amazonaws.com", {} }, .{ "*.us-gov-west-1.rds.amazonaws.com", {} }, .{ "*.us-northeast-1.rds.amazonaws.com", {} }, .{ "*.us-west-1.rds.amazonaws.com", {} }, .{ "*.us-west-2.rds.amazonaws.com", {} }, .{ "s3.dualstack.cn-north-1.amazonaws.com.cn", {} }, .{ "s3-accesspoint.dualstack.cn-north-1.amazonaws.com.cn", {} }, .{ "s3-website.dualstack.cn-north-1.amazonaws.com.cn", {} }, .{ "s3.cn-north-1.amazonaws.com.cn", {} }, .{ "s3-accesspoint.cn-north-1.amazonaws.com.cn", {} }, .{ "s3-deprecated.cn-north-1.amazonaws.com.cn", {} }, .{ "s3-object-lambda.cn-north-1.amazonaws.com.cn", {} }, .{ "s3-website.cn-north-1.amazonaws.com.cn", {} }, .{ "s3.dualstack.cn-northwest-1.amazonaws.com.cn", {} }, .{ "s3-accesspoint.dualstack.cn-northwest-1.amazonaws.com.cn", {} }, .{ "s3.cn-northwest-1.amazonaws.com.cn", {} }, .{ "s3-accesspoint.cn-northwest-1.amazonaws.com.cn", {} }, .{ "s3-object-lambda.cn-northwest-1.amazonaws.com.cn", {} }, .{ "s3-website.cn-northwest-1.amazonaws.com.cn", {} }, .{ "s3.dualstack.af-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.af-south-1.amazonaws.com", {} }, .{ "s3-website.dualstack.af-south-1.amazonaws.com", {} }, .{ "s3.af-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.af-south-1.amazonaws.com", {} }, .{ "s3-object-lambda.af-south-1.amazonaws.com", {} }, .{ "s3-website.af-south-1.amazonaws.com", {} }, .{ "s3.dualstack.ap-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-east-1.amazonaws.com", {} }, .{ "s3.ap-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.ap-east-1.amazonaws.com", {} }, .{ "s3-object-lambda.ap-east-1.amazonaws.com", {} }, .{ "s3-website.ap-east-1.amazonaws.com", {} }, .{ "s3.dualstack.ap-northeast-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-northeast-1.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-northeast-1.amazonaws.com", {} }, .{ "s3.ap-northeast-1.amazonaws.com", {} }, .{ "s3-accesspoint.ap-northeast-1.amazonaws.com", {} }, .{ "s3-object-lambda.ap-northeast-1.amazonaws.com", {} }, .{ "s3-website.ap-northeast-1.amazonaws.com", {} }, .{ "s3.dualstack.ap-northeast-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-northeast-2.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-northeast-2.amazonaws.com", {} }, .{ "s3.ap-northeast-2.amazonaws.com", {} }, .{ "s3-accesspoint.ap-northeast-2.amazonaws.com", {} }, .{ "s3-object-lambda.ap-northeast-2.amazonaws.com", {} }, .{ "s3-website.ap-northeast-2.amazonaws.com", {} }, .{ "s3.dualstack.ap-northeast-3.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-northeast-3.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-northeast-3.amazonaws.com", {} }, .{ "s3.ap-northeast-3.amazonaws.com", {} }, .{ "s3-accesspoint.ap-northeast-3.amazonaws.com", {} }, .{ "s3-object-lambda.ap-northeast-3.amazonaws.com", {} }, .{ "s3-website.ap-northeast-3.amazonaws.com", {} }, .{ "s3.dualstack.ap-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-south-1.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-south-1.amazonaws.com", {} }, .{ "s3.ap-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.ap-south-1.amazonaws.com", {} }, .{ "s3-object-lambda.ap-south-1.amazonaws.com", {} }, .{ "s3-website.ap-south-1.amazonaws.com", {} }, .{ "s3.dualstack.ap-south-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-south-2.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-south-2.amazonaws.com", {} }, .{ "s3.ap-south-2.amazonaws.com", {} }, .{ "s3-accesspoint.ap-south-2.amazonaws.com", {} }, .{ "s3-object-lambda.ap-south-2.amazonaws.com", {} }, .{ "s3-website.ap-south-2.amazonaws.com", {} }, .{ "s3.dualstack.ap-southeast-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-southeast-1.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-southeast-1.amazonaws.com", {} }, .{ "s3.ap-southeast-1.amazonaws.com", {} }, .{ "s3-accesspoint.ap-southeast-1.amazonaws.com", {} }, .{ "s3-object-lambda.ap-southeast-1.amazonaws.com", {} }, .{ "s3-website.ap-southeast-1.amazonaws.com", {} }, .{ "s3.dualstack.ap-southeast-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-southeast-2.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-southeast-2.amazonaws.com", {} }, .{ "s3.ap-southeast-2.amazonaws.com", {} }, .{ "s3-accesspoint.ap-southeast-2.amazonaws.com", {} }, .{ "s3-object-lambda.ap-southeast-2.amazonaws.com", {} }, .{ "s3-website.ap-southeast-2.amazonaws.com", {} }, .{ "s3.dualstack.ap-southeast-3.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-southeast-3.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-southeast-3.amazonaws.com", {} }, .{ "s3.ap-southeast-3.amazonaws.com", {} }, .{ "s3-accesspoint.ap-southeast-3.amazonaws.com", {} }, .{ "s3-object-lambda.ap-southeast-3.amazonaws.com", {} }, .{ "s3-website.ap-southeast-3.amazonaws.com", {} }, .{ "s3.dualstack.ap-southeast-4.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-southeast-4.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-southeast-4.amazonaws.com", {} }, .{ "s3.ap-southeast-4.amazonaws.com", {} }, .{ "s3-accesspoint.ap-southeast-4.amazonaws.com", {} }, .{ "s3-object-lambda.ap-southeast-4.amazonaws.com", {} }, .{ "s3-website.ap-southeast-4.amazonaws.com", {} }, .{ "s3.dualstack.ap-southeast-5.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ap-southeast-5.amazonaws.com", {} }, .{ "s3-website.dualstack.ap-southeast-5.amazonaws.com", {} }, .{ "s3.ap-southeast-5.amazonaws.com", {} }, .{ "s3-accesspoint.ap-southeast-5.amazonaws.com", {} }, .{ "s3-deprecated.ap-southeast-5.amazonaws.com", {} }, .{ "s3-object-lambda.ap-southeast-5.amazonaws.com", {} }, .{ "s3-website.ap-southeast-5.amazonaws.com", {} }, .{ "s3.dualstack.ca-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ca-central-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.ca-central-1.amazonaws.com", {} }, .{ "s3-fips.dualstack.ca-central-1.amazonaws.com", {} }, .{ "s3-website.dualstack.ca-central-1.amazonaws.com", {} }, .{ "s3.ca-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.ca-central-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.ca-central-1.amazonaws.com", {} }, .{ "s3-fips.ca-central-1.amazonaws.com", {} }, .{ "s3-object-lambda.ca-central-1.amazonaws.com", {} }, .{ "s3-website.ca-central-1.amazonaws.com", {} }, .{ "s3.dualstack.ca-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.ca-west-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.ca-west-1.amazonaws.com", {} }, .{ "s3-fips.dualstack.ca-west-1.amazonaws.com", {} }, .{ "s3-website.dualstack.ca-west-1.amazonaws.com", {} }, .{ "s3.ca-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.ca-west-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.ca-west-1.amazonaws.com", {} }, .{ "s3-fips.ca-west-1.amazonaws.com", {} }, .{ "s3-object-lambda.ca-west-1.amazonaws.com", {} }, .{ "s3-website.ca-west-1.amazonaws.com", {} }, .{ "s3.dualstack.eu-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-central-1.amazonaws.com", {} }, .{ "s3-website.dualstack.eu-central-1.amazonaws.com", {} }, .{ "s3.eu-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.eu-central-1.amazonaws.com", {} }, .{ "s3-object-lambda.eu-central-1.amazonaws.com", {} }, .{ "s3-website.eu-central-1.amazonaws.com", {} }, .{ "s3.dualstack.eu-central-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-central-2.amazonaws.com", {} }, .{ "s3-website.dualstack.eu-central-2.amazonaws.com", {} }, .{ "s3.eu-central-2.amazonaws.com", {} }, .{ "s3-accesspoint.eu-central-2.amazonaws.com", {} }, .{ "s3-object-lambda.eu-central-2.amazonaws.com", {} }, .{ "s3-website.eu-central-2.amazonaws.com", {} }, .{ "s3.dualstack.eu-north-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-north-1.amazonaws.com", {} }, .{ "s3.eu-north-1.amazonaws.com", {} }, .{ "s3-accesspoint.eu-north-1.amazonaws.com", {} }, .{ "s3-object-lambda.eu-north-1.amazonaws.com", {} }, .{ "s3-website.eu-north-1.amazonaws.com", {} }, .{ "s3.dualstack.eu-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-south-1.amazonaws.com", {} }, .{ "s3-website.dualstack.eu-south-1.amazonaws.com", {} }, .{ "s3.eu-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.eu-south-1.amazonaws.com", {} }, .{ "s3-object-lambda.eu-south-1.amazonaws.com", {} }, .{ "s3-website.eu-south-1.amazonaws.com", {} }, .{ "s3.dualstack.eu-south-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-south-2.amazonaws.com", {} }, .{ "s3-website.dualstack.eu-south-2.amazonaws.com", {} }, .{ "s3.eu-south-2.amazonaws.com", {} }, .{ "s3-accesspoint.eu-south-2.amazonaws.com", {} }, .{ "s3-object-lambda.eu-south-2.amazonaws.com", {} }, .{ "s3-website.eu-south-2.amazonaws.com", {} }, .{ "s3.dualstack.eu-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-west-1.amazonaws.com", {} }, .{ "s3-website.dualstack.eu-west-1.amazonaws.com", {} }, .{ "s3.eu-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.eu-west-1.amazonaws.com", {} }, .{ "s3-deprecated.eu-west-1.amazonaws.com", {} }, .{ "s3-object-lambda.eu-west-1.amazonaws.com", {} }, .{ "s3-website.eu-west-1.amazonaws.com", {} }, .{ "s3.dualstack.eu-west-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-west-2.amazonaws.com", {} }, .{ "s3.eu-west-2.amazonaws.com", {} }, .{ "s3-accesspoint.eu-west-2.amazonaws.com", {} }, .{ "s3-object-lambda.eu-west-2.amazonaws.com", {} }, .{ "s3-website.eu-west-2.amazonaws.com", {} }, .{ "s3.dualstack.eu-west-3.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.eu-west-3.amazonaws.com", {} }, .{ "s3-website.dualstack.eu-west-3.amazonaws.com", {} }, .{ "s3.eu-west-3.amazonaws.com", {} }, .{ "s3-accesspoint.eu-west-3.amazonaws.com", {} }, .{ "s3-object-lambda.eu-west-3.amazonaws.com", {} }, .{ "s3-website.eu-west-3.amazonaws.com", {} }, .{ "s3.dualstack.il-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.il-central-1.amazonaws.com", {} }, .{ "s3-website.dualstack.il-central-1.amazonaws.com", {} }, .{ "s3.il-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.il-central-1.amazonaws.com", {} }, .{ "s3-object-lambda.il-central-1.amazonaws.com", {} }, .{ "s3-website.il-central-1.amazonaws.com", {} }, .{ "s3.dualstack.me-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.me-central-1.amazonaws.com", {} }, .{ "s3-website.dualstack.me-central-1.amazonaws.com", {} }, .{ "s3.me-central-1.amazonaws.com", {} }, .{ "s3-accesspoint.me-central-1.amazonaws.com", {} }, .{ "s3-object-lambda.me-central-1.amazonaws.com", {} }, .{ "s3-website.me-central-1.amazonaws.com", {} }, .{ "s3.dualstack.me-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.me-south-1.amazonaws.com", {} }, .{ "s3.me-south-1.amazonaws.com", {} }, .{ "s3-accesspoint.me-south-1.amazonaws.com", {} }, .{ "s3-object-lambda.me-south-1.amazonaws.com", {} }, .{ "s3-website.me-south-1.amazonaws.com", {} }, .{ "s3.amazonaws.com", {} }, .{ "s3-1.amazonaws.com", {} }, .{ "s3-ap-east-1.amazonaws.com", {} }, .{ "s3-ap-northeast-1.amazonaws.com", {} }, .{ "s3-ap-northeast-2.amazonaws.com", {} }, .{ "s3-ap-northeast-3.amazonaws.com", {} }, .{ "s3-ap-south-1.amazonaws.com", {} }, .{ "s3-ap-southeast-1.amazonaws.com", {} }, .{ "s3-ap-southeast-2.amazonaws.com", {} }, .{ "s3-ca-central-1.amazonaws.com", {} }, .{ "s3-eu-central-1.amazonaws.com", {} }, .{ "s3-eu-north-1.amazonaws.com", {} }, .{ "s3-eu-west-1.amazonaws.com", {} }, .{ "s3-eu-west-2.amazonaws.com", {} }, .{ "s3-eu-west-3.amazonaws.com", {} }, .{ "s3-external-1.amazonaws.com", {} }, .{ "s3-fips-us-gov-east-1.amazonaws.com", {} }, .{ "s3-fips-us-gov-west-1.amazonaws.com", {} }, .{ "mrap.accesspoint.s3-global.amazonaws.com", {} }, .{ "s3-me-south-1.amazonaws.com", {} }, .{ "s3-sa-east-1.amazonaws.com", {} }, .{ "s3-us-east-2.amazonaws.com", {} }, .{ "s3-us-gov-east-1.amazonaws.com", {} }, .{ "s3-us-gov-west-1.amazonaws.com", {} }, .{ "s3-us-west-1.amazonaws.com", {} }, .{ "s3-us-west-2.amazonaws.com", {} }, .{ "s3-website-ap-northeast-1.amazonaws.com", {} }, .{ "s3-website-ap-southeast-1.amazonaws.com", {} }, .{ "s3-website-ap-southeast-2.amazonaws.com", {} }, .{ "s3-website-eu-west-1.amazonaws.com", {} }, .{ "s3-website-sa-east-1.amazonaws.com", {} }, .{ "s3-website-us-east-1.amazonaws.com", {} }, .{ "s3-website-us-gov-west-1.amazonaws.com", {} }, .{ "s3-website-us-west-1.amazonaws.com", {} }, .{ "s3-website-us-west-2.amazonaws.com", {} }, .{ "s3.dualstack.sa-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.sa-east-1.amazonaws.com", {} }, .{ "s3-website.dualstack.sa-east-1.amazonaws.com", {} }, .{ "s3.sa-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.sa-east-1.amazonaws.com", {} }, .{ "s3-object-lambda.sa-east-1.amazonaws.com", {} }, .{ "s3-website.sa-east-1.amazonaws.com", {} }, .{ "s3.dualstack.us-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.us-east-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.us-east-1.amazonaws.com", {} }, .{ "s3-fips.dualstack.us-east-1.amazonaws.com", {} }, .{ "s3-website.dualstack.us-east-1.amazonaws.com", {} }, .{ "s3.us-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.us-east-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.us-east-1.amazonaws.com", {} }, .{ "s3-deprecated.us-east-1.amazonaws.com", {} }, .{ "s3-fips.us-east-1.amazonaws.com", {} }, .{ "s3-object-lambda.us-east-1.amazonaws.com", {} }, .{ "s3-website.us-east-1.amazonaws.com", {} }, .{ "s3.dualstack.us-east-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.us-east-2.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.us-east-2.amazonaws.com", {} }, .{ "s3-fips.dualstack.us-east-2.amazonaws.com", {} }, .{ "s3-website.dualstack.us-east-2.amazonaws.com", {} }, .{ "s3.us-east-2.amazonaws.com", {} }, .{ "s3-accesspoint.us-east-2.amazonaws.com", {} }, .{ "s3-accesspoint-fips.us-east-2.amazonaws.com", {} }, .{ "s3-deprecated.us-east-2.amazonaws.com", {} }, .{ "s3-fips.us-east-2.amazonaws.com", {} }, .{ "s3-object-lambda.us-east-2.amazonaws.com", {} }, .{ "s3-website.us-east-2.amazonaws.com", {} }, .{ "s3.dualstack.us-gov-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.us-gov-east-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.us-gov-east-1.amazonaws.com", {} }, .{ "s3-fips.dualstack.us-gov-east-1.amazonaws.com", {} }, .{ "s3-website.dualstack.us-gov-east-1.amazonaws.com", {} }, .{ "s3.us-gov-east-1.amazonaws.com", {} }, .{ "s3-accesspoint.us-gov-east-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.us-gov-east-1.amazonaws.com", {} }, .{ "s3-fips.us-gov-east-1.amazonaws.com", {} }, .{ "s3-object-lambda.us-gov-east-1.amazonaws.com", {} }, .{ "s3-website.us-gov-east-1.amazonaws.com", {} }, .{ "s3.dualstack.us-gov-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.us-gov-west-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.us-gov-west-1.amazonaws.com", {} }, .{ "s3-fips.dualstack.us-gov-west-1.amazonaws.com", {} }, .{ "s3-website.dualstack.us-gov-west-1.amazonaws.com", {} }, .{ "s3.us-gov-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.us-gov-west-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.us-gov-west-1.amazonaws.com", {} }, .{ "s3-fips.us-gov-west-1.amazonaws.com", {} }, .{ "s3-object-lambda.us-gov-west-1.amazonaws.com", {} }, .{ "s3-website.us-gov-west-1.amazonaws.com", {} }, .{ "s3.dualstack.us-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.us-west-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.us-west-1.amazonaws.com", {} }, .{ "s3-fips.dualstack.us-west-1.amazonaws.com", {} }, .{ "s3-website.dualstack.us-west-1.amazonaws.com", {} }, .{ "s3.us-west-1.amazonaws.com", {} }, .{ "s3-accesspoint.us-west-1.amazonaws.com", {} }, .{ "s3-accesspoint-fips.us-west-1.amazonaws.com", {} }, .{ "s3-fips.us-west-1.amazonaws.com", {} }, .{ "s3-object-lambda.us-west-1.amazonaws.com", {} }, .{ "s3-website.us-west-1.amazonaws.com", {} }, .{ "s3.dualstack.us-west-2.amazonaws.com", {} }, .{ "s3-accesspoint.dualstack.us-west-2.amazonaws.com", {} }, .{ "s3-accesspoint-fips.dualstack.us-west-2.amazonaws.com", {} }, .{ "s3-fips.dualstack.us-west-2.amazonaws.com", {} }, .{ "s3-website.dualstack.us-west-2.amazonaws.com", {} }, .{ "s3.us-west-2.amazonaws.com", {} }, .{ "s3-accesspoint.us-west-2.amazonaws.com", {} }, .{ "s3-accesspoint-fips.us-west-2.amazonaws.com", {} }, .{ "s3-deprecated.us-west-2.amazonaws.com", {} }, .{ "s3-fips.us-west-2.amazonaws.com", {} }, .{ "s3-object-lambda.us-west-2.amazonaws.com", {} }, .{ "s3-website.us-west-2.amazonaws.com", {} }, .{ "labeling.ap-northeast-1.sagemaker.aws", {} }, .{ "labeling.ap-northeast-2.sagemaker.aws", {} }, .{ "labeling.ap-south-1.sagemaker.aws", {} }, .{ "labeling.ap-southeast-1.sagemaker.aws", {} }, .{ "labeling.ap-southeast-2.sagemaker.aws", {} }, .{ "labeling.ca-central-1.sagemaker.aws", {} }, .{ "labeling.eu-central-1.sagemaker.aws", {} }, .{ "labeling.eu-west-1.sagemaker.aws", {} }, .{ "labeling.eu-west-2.sagemaker.aws", {} }, .{ "labeling.us-east-1.sagemaker.aws", {} }, .{ "labeling.us-east-2.sagemaker.aws", {} }, .{ "labeling.us-west-2.sagemaker.aws", {} }, .{ "notebook.af-south-1.sagemaker.aws", {} }, .{ "notebook.ap-east-1.sagemaker.aws", {} }, .{ "notebook.ap-northeast-1.sagemaker.aws", {} }, .{ "notebook.ap-northeast-2.sagemaker.aws", {} }, .{ "notebook.ap-northeast-3.sagemaker.aws", {} }, .{ "notebook.ap-south-1.sagemaker.aws", {} }, .{ "notebook.ap-south-2.sagemaker.aws", {} }, .{ "notebook.ap-southeast-1.sagemaker.aws", {} }, .{ "notebook.ap-southeast-2.sagemaker.aws", {} }, .{ "notebook.ap-southeast-3.sagemaker.aws", {} }, .{ "notebook.ap-southeast-4.sagemaker.aws", {} }, .{ "notebook.ca-central-1.sagemaker.aws", {} }, .{ "notebook-fips.ca-central-1.sagemaker.aws", {} }, .{ "notebook.ca-west-1.sagemaker.aws", {} }, .{ "notebook-fips.ca-west-1.sagemaker.aws", {} }, .{ "notebook.eu-central-1.sagemaker.aws", {} }, .{ "notebook.eu-central-2.sagemaker.aws", {} }, .{ "notebook.eu-north-1.sagemaker.aws", {} }, .{ "notebook.eu-south-1.sagemaker.aws", {} }, .{ "notebook.eu-south-2.sagemaker.aws", {} }, .{ "notebook.eu-west-1.sagemaker.aws", {} }, .{ "notebook.eu-west-2.sagemaker.aws", {} }, .{ "notebook.eu-west-3.sagemaker.aws", {} }, .{ "notebook.il-central-1.sagemaker.aws", {} }, .{ "notebook.me-central-1.sagemaker.aws", {} }, .{ "notebook.me-south-1.sagemaker.aws", {} }, .{ "notebook.sa-east-1.sagemaker.aws", {} }, .{ "notebook.us-east-1.sagemaker.aws", {} }, .{ "notebook-fips.us-east-1.sagemaker.aws", {} }, .{ "notebook.us-east-2.sagemaker.aws", {} }, .{ "notebook-fips.us-east-2.sagemaker.aws", {} }, .{ "notebook.us-gov-east-1.sagemaker.aws", {} }, .{ "notebook-fips.us-gov-east-1.sagemaker.aws", {} }, .{ "notebook.us-gov-west-1.sagemaker.aws", {} }, .{ "notebook-fips.us-gov-west-1.sagemaker.aws", {} }, .{ "notebook.us-west-1.sagemaker.aws", {} }, .{ "notebook-fips.us-west-1.sagemaker.aws", {} }, .{ "notebook.us-west-2.sagemaker.aws", {} }, .{ "notebook-fips.us-west-2.sagemaker.aws", {} }, .{ "notebook.cn-north-1.sagemaker.com.cn", {} }, .{ "notebook.cn-northwest-1.sagemaker.com.cn", {} }, .{ "studio.af-south-1.sagemaker.aws", {} }, .{ "studio.ap-east-1.sagemaker.aws", {} }, .{ "studio.ap-northeast-1.sagemaker.aws", {} }, .{ "studio.ap-northeast-2.sagemaker.aws", {} }, .{ "studio.ap-northeast-3.sagemaker.aws", {} }, .{ "studio.ap-south-1.sagemaker.aws", {} }, .{ "studio.ap-southeast-1.sagemaker.aws", {} }, .{ "studio.ap-southeast-2.sagemaker.aws", {} }, .{ "studio.ap-southeast-3.sagemaker.aws", {} }, .{ "studio.ca-central-1.sagemaker.aws", {} }, .{ "studio.eu-central-1.sagemaker.aws", {} }, .{ "studio.eu-central-2.sagemaker.aws", {} }, .{ "studio.eu-north-1.sagemaker.aws", {} }, .{ "studio.eu-south-1.sagemaker.aws", {} }, .{ "studio.eu-south-2.sagemaker.aws", {} }, .{ "studio.eu-west-1.sagemaker.aws", {} }, .{ "studio.eu-west-2.sagemaker.aws", {} }, .{ "studio.eu-west-3.sagemaker.aws", {} }, .{ "studio.il-central-1.sagemaker.aws", {} }, .{ "studio.me-central-1.sagemaker.aws", {} }, .{ "studio.me-south-1.sagemaker.aws", {} }, .{ "studio.sa-east-1.sagemaker.aws", {} }, .{ "studio.us-east-1.sagemaker.aws", {} }, .{ "studio.us-east-2.sagemaker.aws", {} }, .{ "studio.us-gov-east-1.sagemaker.aws", {} }, .{ "studio-fips.us-gov-east-1.sagemaker.aws", {} }, .{ "studio.us-gov-west-1.sagemaker.aws", {} }, .{ "studio-fips.us-gov-west-1.sagemaker.aws", {} }, .{ "studio.us-west-1.sagemaker.aws", {} }, .{ "studio.us-west-2.sagemaker.aws", {} }, .{ "studio.cn-north-1.sagemaker.com.cn", {} }, .{ "studio.cn-northwest-1.sagemaker.com.cn", {} }, .{ "*.experiments.sagemaker.aws", {} }, .{ "analytics-gateway.ap-northeast-1.amazonaws.com", {} }, .{ "analytics-gateway.ap-northeast-2.amazonaws.com", {} }, .{ "analytics-gateway.ap-south-1.amazonaws.com", {} }, .{ "analytics-gateway.ap-southeast-1.amazonaws.com", {} }, .{ "analytics-gateway.ap-southeast-2.amazonaws.com", {} }, .{ "analytics-gateway.eu-central-1.amazonaws.com", {} }, .{ "analytics-gateway.eu-west-1.amazonaws.com", {} }, .{ "analytics-gateway.us-east-1.amazonaws.com", {} }, .{ "analytics-gateway.us-east-2.amazonaws.com", {} }, .{ "analytics-gateway.us-west-2.amazonaws.com", {} }, .{ "amplifyapp.com", {} }, .{ "*.awsapprunner.com", {} }, .{ "webview-assets.aws-cloud9.af-south-1.amazonaws.com", {} }, .{ "vfs.cloud9.af-south-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.af-south-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ap-east-1.amazonaws.com", {} }, .{ "vfs.cloud9.ap-east-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.ap-east-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ap-northeast-1.amazonaws.com", {} }, .{ "vfs.cloud9.ap-northeast-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.ap-northeast-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ap-northeast-2.amazonaws.com", {} }, .{ "vfs.cloud9.ap-northeast-2.amazonaws.com", {} }, .{ "webview-assets.cloud9.ap-northeast-2.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ap-northeast-3.amazonaws.com", {} }, .{ "vfs.cloud9.ap-northeast-3.amazonaws.com", {} }, .{ "webview-assets.cloud9.ap-northeast-3.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ap-south-1.amazonaws.com", {} }, .{ "vfs.cloud9.ap-south-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.ap-south-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ap-southeast-1.amazonaws.com", {} }, .{ "vfs.cloud9.ap-southeast-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.ap-southeast-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ap-southeast-2.amazonaws.com", {} }, .{ "vfs.cloud9.ap-southeast-2.amazonaws.com", {} }, .{ "webview-assets.cloud9.ap-southeast-2.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.ca-central-1.amazonaws.com", {} }, .{ "vfs.cloud9.ca-central-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.ca-central-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.eu-central-1.amazonaws.com", {} }, .{ "vfs.cloud9.eu-central-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.eu-central-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.eu-north-1.amazonaws.com", {} }, .{ "vfs.cloud9.eu-north-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.eu-north-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.eu-south-1.amazonaws.com", {} }, .{ "vfs.cloud9.eu-south-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.eu-south-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.eu-west-1.amazonaws.com", {} }, .{ "vfs.cloud9.eu-west-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.eu-west-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.eu-west-2.amazonaws.com", {} }, .{ "vfs.cloud9.eu-west-2.amazonaws.com", {} }, .{ "webview-assets.cloud9.eu-west-2.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.eu-west-3.amazonaws.com", {} }, .{ "vfs.cloud9.eu-west-3.amazonaws.com", {} }, .{ "webview-assets.cloud9.eu-west-3.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.il-central-1.amazonaws.com", {} }, .{ "vfs.cloud9.il-central-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.me-south-1.amazonaws.com", {} }, .{ "vfs.cloud9.me-south-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.me-south-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.sa-east-1.amazonaws.com", {} }, .{ "vfs.cloud9.sa-east-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.sa-east-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.us-east-1.amazonaws.com", {} }, .{ "vfs.cloud9.us-east-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.us-east-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.us-east-2.amazonaws.com", {} }, .{ "vfs.cloud9.us-east-2.amazonaws.com", {} }, .{ "webview-assets.cloud9.us-east-2.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.us-west-1.amazonaws.com", {} }, .{ "vfs.cloud9.us-west-1.amazonaws.com", {} }, .{ "webview-assets.cloud9.us-west-1.amazonaws.com", {} }, .{ "webview-assets.aws-cloud9.us-west-2.amazonaws.com", {} }, .{ "vfs.cloud9.us-west-2.amazonaws.com", {} }, .{ "webview-assets.cloud9.us-west-2.amazonaws.com", {} }, .{ "awsapps.com", {} }, .{ "cn-north-1.eb.amazonaws.com.cn", {} }, .{ "cn-northwest-1.eb.amazonaws.com.cn", {} }, .{ "elasticbeanstalk.com", {} }, .{ "af-south-1.elasticbeanstalk.com", {} }, .{ "ap-east-1.elasticbeanstalk.com", {} }, .{ "ap-northeast-1.elasticbeanstalk.com", {} }, .{ "ap-northeast-2.elasticbeanstalk.com", {} }, .{ "ap-northeast-3.elasticbeanstalk.com", {} }, .{ "ap-south-1.elasticbeanstalk.com", {} }, .{ "ap-southeast-1.elasticbeanstalk.com", {} }, .{ "ap-southeast-2.elasticbeanstalk.com", {} }, .{ "ap-southeast-3.elasticbeanstalk.com", {} }, .{ "ap-southeast-5.elasticbeanstalk.com", {} }, .{ "ap-southeast-7.elasticbeanstalk.com", {} }, .{ "ca-central-1.elasticbeanstalk.com", {} }, .{ "eu-central-1.elasticbeanstalk.com", {} }, .{ "eu-north-1.elasticbeanstalk.com", {} }, .{ "eu-south-1.elasticbeanstalk.com", {} }, .{ "eu-south-2.elasticbeanstalk.com", {} }, .{ "eu-west-1.elasticbeanstalk.com", {} }, .{ "eu-west-2.elasticbeanstalk.com", {} }, .{ "eu-west-3.elasticbeanstalk.com", {} }, .{ "il-central-1.elasticbeanstalk.com", {} }, .{ "me-central-1.elasticbeanstalk.com", {} }, .{ "me-south-1.elasticbeanstalk.com", {} }, .{ "sa-east-1.elasticbeanstalk.com", {} }, .{ "us-east-1.elasticbeanstalk.com", {} }, .{ "us-east-2.elasticbeanstalk.com", {} }, .{ "us-gov-east-1.elasticbeanstalk.com", {} }, .{ "us-gov-west-1.elasticbeanstalk.com", {} }, .{ "us-west-1.elasticbeanstalk.com", {} }, .{ "us-west-2.elasticbeanstalk.com", {} }, .{ "*.elb.amazonaws.com.cn", {} }, .{ "*.elb.amazonaws.com", {} }, .{ "awsglobalaccelerator.com", {} }, .{ "lambda-url.af-south-1.on.aws", {} }, .{ "lambda-url.ap-east-1.on.aws", {} }, .{ "lambda-url.ap-northeast-1.on.aws", {} }, .{ "lambda-url.ap-northeast-2.on.aws", {} }, .{ "lambda-url.ap-northeast-3.on.aws", {} }, .{ "lambda-url.ap-south-1.on.aws", {} }, .{ "lambda-url.ap-southeast-1.on.aws", {} }, .{ "lambda-url.ap-southeast-2.on.aws", {} }, .{ "lambda-url.ap-southeast-3.on.aws", {} }, .{ "lambda-url.ca-central-1.on.aws", {} }, .{ "lambda-url.eu-central-1.on.aws", {} }, .{ "lambda-url.eu-north-1.on.aws", {} }, .{ "lambda-url.eu-south-1.on.aws", {} }, .{ "lambda-url.eu-west-1.on.aws", {} }, .{ "lambda-url.eu-west-2.on.aws", {} }, .{ "lambda-url.eu-west-3.on.aws", {} }, .{ "lambda-url.me-south-1.on.aws", {} }, .{ "lambda-url.sa-east-1.on.aws", {} }, .{ "lambda-url.us-east-1.on.aws", {} }, .{ "lambda-url.us-east-2.on.aws", {} }, .{ "lambda-url.us-west-1.on.aws", {} }, .{ "lambda-url.us-west-2.on.aws", {} }, .{ "*.private.repost.aws", {} }, .{ "transfer-webapp.af-south-1.on.aws", {} }, .{ "transfer-webapp.ap-east-1.on.aws", {} }, .{ "transfer-webapp.ap-northeast-1.on.aws", {} }, .{ "transfer-webapp.ap-northeast-2.on.aws", {} }, .{ "transfer-webapp.ap-northeast-3.on.aws", {} }, .{ "transfer-webapp.ap-south-1.on.aws", {} }, .{ "transfer-webapp.ap-south-2.on.aws", {} }, .{ "transfer-webapp.ap-southeast-1.on.aws", {} }, .{ "transfer-webapp.ap-southeast-2.on.aws", {} }, .{ "transfer-webapp.ap-southeast-3.on.aws", {} }, .{ "transfer-webapp.ap-southeast-4.on.aws", {} }, .{ "transfer-webapp.ap-southeast-5.on.aws", {} }, .{ "transfer-webapp.ap-southeast-7.on.aws", {} }, .{ "transfer-webapp.ca-central-1.on.aws", {} }, .{ "transfer-webapp.ca-west-1.on.aws", {} }, .{ "transfer-webapp.eu-central-1.on.aws", {} }, .{ "transfer-webapp.eu-central-2.on.aws", {} }, .{ "transfer-webapp.eu-north-1.on.aws", {} }, .{ "transfer-webapp.eu-south-1.on.aws", {} }, .{ "transfer-webapp.eu-south-2.on.aws", {} }, .{ "transfer-webapp.eu-west-1.on.aws", {} }, .{ "transfer-webapp.eu-west-2.on.aws", {} }, .{ "transfer-webapp.eu-west-3.on.aws", {} }, .{ "transfer-webapp.il-central-1.on.aws", {} }, .{ "transfer-webapp.me-central-1.on.aws", {} }, .{ "transfer-webapp.me-south-1.on.aws", {} }, .{ "transfer-webapp.mx-central-1.on.aws", {} }, .{ "transfer-webapp.sa-east-1.on.aws", {} }, .{ "transfer-webapp.us-east-1.on.aws", {} }, .{ "transfer-webapp.us-east-2.on.aws", {} }, .{ "transfer-webapp.us-gov-east-1.on.aws", {} }, .{ "transfer-webapp-fips.us-gov-east-1.on.aws", {} }, .{ "transfer-webapp.us-gov-west-1.on.aws", {} }, .{ "transfer-webapp-fips.us-gov-west-1.on.aws", {} }, .{ "transfer-webapp.us-west-1.on.aws", {} }, .{ "transfer-webapp.us-west-2.on.aws", {} }, .{ "transfer-webapp.cn-north-1.on.amazonwebservices.com.cn", {} }, .{ "transfer-webapp.cn-northwest-1.on.amazonwebservices.com.cn", {} }, .{ "eero.online", {} }, .{ "eero-stage.online", {} }, .{ "antagonist.cloud", {} }, .{ "apigee.io", {} }, .{ "panel.dev", {} }, .{ "siiites.com", {} }, .{ "int.apple", {} }, .{ "*.cloud.int.apple", {} }, .{ "*.r.cloud.int.apple", {} }, .{ "*.ap-north-1.r.cloud.int.apple", {} }, .{ "*.ap-south-1.r.cloud.int.apple", {} }, .{ "*.ap-south-2.r.cloud.int.apple", {} }, .{ "*.eu-central-1.r.cloud.int.apple", {} }, .{ "*.eu-north-1.r.cloud.int.apple", {} }, .{ "*.us-central-1.r.cloud.int.apple", {} }, .{ "*.us-central-2.r.cloud.int.apple", {} }, .{ "*.us-east-1.r.cloud.int.apple", {} }, .{ "*.us-east-2.r.cloud.int.apple", {} }, .{ "*.us-west-1.r.cloud.int.apple", {} }, .{ "*.us-west-2.r.cloud.int.apple", {} }, .{ "*.us-west-3.r.cloud.int.apple", {} }, .{ "appspacehosted.com", {} }, .{ "appspaceusercontent.com", {} }, .{ "appudo.net", {} }, .{ "appwrite.global", {} }, .{ "appwrite.network", {} }, .{ "*.appwrite.run", {} }, .{ "on-aptible.com", {} }, .{ "f5.si", {} }, .{ "arvanedge.ir", {} }, .{ "user.aseinet.ne.jp", {} }, .{ "gv.vc", {} }, .{ "d.gv.vc", {} }, .{ "user.party.eus", {} }, .{ "pimienta.org", {} }, .{ "poivron.org", {} }, .{ "potager.org", {} }, .{ "sweetpepper.org", {} }, .{ "myasustor.com", {} }, .{ "cdn.prod.atlassian-dev.net", {} }, .{ "myfritz.link", {} }, .{ "myfritz.net", {} }, .{ "*.awdev.ca", {} }, .{ "*.advisor.ws", {} }, .{ "ecommerce-shop.pl", {} }, .{ "b-data.io", {} }, .{ "balena-devices.com", {} }, .{ "base.ec", {} }, .{ "official.ec", {} }, .{ "buyshop.jp", {} }, .{ "fashionstore.jp", {} }, .{ "handcrafted.jp", {} }, .{ "kawaiishop.jp", {} }, .{ "supersale.jp", {} }, .{ "theshop.jp", {} }, .{ "shopselect.net", {} }, .{ "base.shop", {} }, .{ "beagleboard.io", {} }, .{ "bearblog.dev", {} }, .{ "*.beget.app", {} }, .{ "pages.gay", {} }, .{ "bnr.la", {} }, .{ "bitbucket.io", {} }, .{ "blackbaudcdn.net", {} }, .{ "of.je", {} }, .{ "square.site", {} }, .{ "bluebite.io", {} }, .{ "boomla.net", {} }, .{ "boutir.com", {} }, .{ "boxfuse.io", {} }, .{ "square7.ch", {} }, .{ "bplaced.com", {} }, .{ "bplaced.de", {} }, .{ "square7.de", {} }, .{ "bplaced.net", {} }, .{ "square7.net", {} }, .{ "brave.app", {} }, .{ "*.s.brave.app", {} }, .{ "brave.dev", {} }, .{ "*.s.brave.dev", {} }, .{ "brave.io", {} }, .{ "*.s.brave.io", {} }, .{ "shop.brendly.ba", {} }, .{ "shop.brendly.hr", {} }, .{ "shop.brendly.rs", {} }, .{ "browsersafetymark.io", {} }, .{ "radio.am", {} }, .{ "radio.fm", {} }, .{ "cdn.bubble.io", {} }, .{ "bubbleapps.io", {} }, .{ "*.bwcloud-os-instance.de", {} }, .{ "uk0.bigv.io", {} }, .{ "dh.bytemark.co.uk", {} }, .{ "vm.bytemark.co.uk", {} }, .{ "cafjs.com", {} }, .{ "canva-apps.cn", {} }, .{ "my.canvasite.cn", {} }, .{ "canva-apps.com", {} }, .{ "canva-hosted-embed.com", {} }, .{ "canvacode.com", {} }, .{ "rice-labs.com", {} }, .{ "canva.run", {} }, .{ "my.canva.site", {} }, .{ "drr.ac", {} }, .{ "uwu.ai", {} }, .{ "carrd.co", {} }, .{ "crd.co", {} }, .{ "ju.mp", {} }, .{ "api.gov.uk", {} }, .{ "cdn77-storage.com", {} }, .{ "rsc.contentproxy9.cz", {} }, .{ "r.cdn77.net", {} }, .{ "cdn77-ssl.net", {} }, .{ "c.cdn77.org", {} }, .{ "rsc.cdn77.org", {} }, .{ "ssl.origin.cdn77-secure.org", {} }, .{ "za.bz", {} }, .{ "br.com", {} }, .{ "cn.com", {} }, .{ "de.com", {} }, .{ "eu.com", {} }, .{ "jpn.com", {} }, .{ "mex.com", {} }, .{ "ru.com", {} }, .{ "sa.com", {} }, .{ "uk.com", {} }, .{ "us.com", {} }, .{ "za.com", {} }, .{ "com.de", {} }, .{ "gb.net", {} }, .{ "hu.net", {} }, .{ "jp.net", {} }, .{ "se.net", {} }, .{ "uk.net", {} }, .{ "ae.org", {} }, .{ "com.se", {} }, .{ "cx.ua", {} }, .{ "discourse.diy", {} }, .{ "discourse.group", {} }, .{ "discourse.team", {} }, .{ "clerk.app", {} }, .{ "clerkstage.app", {} }, .{ "*.lcl.dev", {} }, .{ "*.lclstage.dev", {} }, .{ "*.stg.dev", {} }, .{ "*.stgstage.dev", {} }, .{ "cleverapps.cc", {} }, .{ "*.services.clever-cloud.com", {} }, .{ "cleverapps.io", {} }, .{ "cleverapps.tech", {} }, .{ "clickrising.net", {} }, .{ "cloudns.asia", {} }, .{ "cloudns.be", {} }, .{ "cloud-ip.biz", {} }, .{ "cloudns.biz", {} }, .{ "cloud-ip.cc", {} }, .{ "cloudns.cc", {} }, .{ "cloudns.ch", {} }, .{ "cloudns.cl", {} }, .{ "cloudns.club", {} }, .{ "abrdns.com", {} }, .{ "dnsabr.com", {} }, .{ "ip-ddns.com", {} }, .{ "cloudns.cx", {} }, .{ "cloudns.eu", {} }, .{ "cloudns.in", {} }, .{ "cloudns.info", {} }, .{ "ddns-ip.net", {} }, .{ "dns-cloud.net", {} }, .{ "dns-dynamic.net", {} }, .{ "cloudns.nz", {} }, .{ "cloudns.org", {} }, .{ "ip-dynamic.org", {} }, .{ "cloudns.ph", {} }, .{ "cloudns.pro", {} }, .{ "cloudns.pw", {} }, .{ "cloudns.us", {} }, .{ "c66.me", {} }, .{ "cloud66.ws", {} }, .{ "jdevcloud.com", {} }, .{ "wpdevcloud.com", {} }, .{ "cloudaccess.host", {} }, .{ "freesite.host", {} }, .{ "cloudaccess.net", {} }, .{ "cloudbeesusercontent.io", {} }, .{ "*.cloudera.site", {} }, .{ "cloudflare.app", {} }, .{ "cf-ipfs.com", {} }, .{ "cloudflare-ipfs.com", {} }, .{ "trycloudflare.com", {} }, .{ "pages.dev", {} }, .{ "r2.dev", {} }, .{ "workers.dev", {} }, .{ "cloudflare.net", {} }, .{ "cdn.cloudflare.net", {} }, .{ "cdn.cloudflareanycast.net", {} }, .{ "cdn.cloudflarecn.net", {} }, .{ "cdn.cloudflareglobal.net", {} }, .{ "cust.cloudscale.ch", {} }, .{ "objects.lpg.cloudscale.ch", {} }, .{ "objects.rma.cloudscale.ch", {} }, .{ "lpg.objectstorage.ch", {} }, .{ "rma.objectstorage.ch", {} }, .{ "wnext.app", {} }, .{ "cnpy.gdn", {} }, .{ "*.otap.co", {} }, .{ "co.ca", {} }, .{ "co.com", {} }, .{ "codeberg.page", {} }, .{ "csb.app", {} }, .{ "preview.csb.app", {} }, .{ "co.nl", {} }, .{ "co.no", {} }, .{ "*.devinapps.com", {} }, .{ "webhosting.be", {} }, .{ "prvw.eu", {} }, .{ "hosting-cluster.nl", {} }, .{ "ctfcloud.net", {} }, .{ "convex.app", {} }, .{ "convex.cloud", {} }, .{ "convex.site", {} }, .{ "ac.ru", {} }, .{ "edu.ru", {} }, .{ "gov.ru", {} }, .{ "int.ru", {} }, .{ "mil.ru", {} }, .{ "corespeed.app", {} }, .{ "dyn.cosidns.de", {} }, .{ "dnsupdater.de", {} }, .{ "dynamisches-dns.de", {} }, .{ "internet-dns.de", {} }, .{ "l-o-g-i-n.de", {} }, .{ "dynamic-dns.info", {} }, .{ "feste-ip.net", {} }, .{ "knx-server.net", {} }, .{ "static-access.net", {} }, .{ "craft.me", {} }, .{ "realm.cz", {} }, .{ "on.crisp.email", {} }, .{ "*.cryptonomic.net", {} }, .{ "cfolks.pl", {} }, .{ "cyon.link", {} }, .{ "cyon.site", {} }, .{ "biz.dk", {} }, .{ "co.dk", {} }, .{ "firm.dk", {} }, .{ "reg.dk", {} }, .{ "store.dk", {} }, .{ "dyndns.dappnode.io", {} }, .{ "builtwithdark.com", {} }, .{ "darklang.io", {} }, .{ "demo.datadetect.com", {} }, .{ "instance.datadetect.com", {} }, .{ "edgestack.me", {} }, .{ "dattolocal.com", {} }, .{ "dattorelay.com", {} }, .{ "dattoweb.com", {} }, .{ "mydatto.com", {} }, .{ "dattolocal.net", {} }, .{ "mydatto.net", {} }, .{ "ddnss.de", {} }, .{ "dyn.ddnss.de", {} }, .{ "dyndns.ddnss.de", {} }, .{ "dyn-ip24.de", {} }, .{ "dyndns1.de", {} }, .{ "home-webserver.de", {} }, .{ "dyn.home-webserver.de", {} }, .{ "myhome-server.de", {} }, .{ "ddnss.org", {} }, .{ "debian.net", {} }, .{ "definima.io", {} }, .{ "definima.net", {} }, .{ "deno.dev", {} }, .{ "deno-staging.dev", {} }, .{ "deno.net", {} }, .{ "dedyn.io", {} }, .{ "deta.app", {} }, .{ "deta.dev", {} }, .{ "deuxfleurs.eu", {} }, .{ "deuxfleurs.page", {} }, .{ "*.at.ply.gg", {} }, .{ "d6.ply.gg", {} }, .{ "joinmc.link", {} }, .{ "playit.plus", {} }, .{ "*.at.playit.plus", {} }, .{ "with.playit.plus", {} }, .{ "icp0.io", {} }, .{ "*.raw.icp0.io", {} }, .{ "icp1.io", {} }, .{ "*.raw.icp1.io", {} }, .{ "*.icp.net", {} }, .{ "caffeine.site", {} }, .{ "caffeine.xyz", {} }, .{ "dfirma.pl", {} }, .{ "dkonto.pl", {} }, .{ "you2.pl", {} }, .{ "ondigitalocean.app", {} }, .{ "*.digitaloceanspaces.com", {} }, .{ "qzz.io", {} }, .{ "us.kg", {} }, .{ "xx.kg", {} }, .{ "dpdns.org", {} }, .{ "discordsays.com", {} }, .{ "discordsez.com", {} }, .{ "jozi.biz", {} }, .{ "ccwu.cc", {} }, .{ "cc.cd", {} }, .{ "us.ci", {} }, .{ "de5.net", {} }, .{ "dnshome.de", {} }, .{ "online.th", {} }, .{ "shop.th", {} }, .{ "co.scot", {} }, .{ "me.scot", {} }, .{ "org.scot", {} }, .{ "drayddns.com", {} }, .{ "shoparena.pl", {} }, .{ "dreamhosters.com", {} }, .{ "durumis.com", {} }, .{ "duckdns.org", {} }, .{ "dy.fi", {} }, .{ "tunk.org", {} }, .{ "dyndns.biz", {} }, .{ "for-better.biz", {} }, .{ "for-more.biz", {} }, .{ "for-some.biz", {} }, .{ "for-the.biz", {} }, .{ "selfip.biz", {} }, .{ "webhop.biz", {} }, .{ "ftpaccess.cc", {} }, .{ "game-server.cc", {} }, .{ "myphotos.cc", {} }, .{ "scrapping.cc", {} }, .{ "blogdns.com", {} }, .{ "cechire.com", {} }, .{ "dnsalias.com", {} }, .{ "dnsdojo.com", {} }, .{ "doesntexist.com", {} }, .{ "dontexist.com", {} }, .{ "doomdns.com", {} }, .{ "dyn-o-saur.com", {} }, .{ "dynalias.com", {} }, .{ "dyndns-at-home.com", {} }, .{ "dyndns-at-work.com", {} }, .{ "dyndns-blog.com", {} }, .{ "dyndns-free.com", {} }, .{ "dyndns-home.com", {} }, .{ "dyndns-ip.com", {} }, .{ "dyndns-mail.com", {} }, .{ "dyndns-office.com", {} }, .{ "dyndns-pics.com", {} }, .{ "dyndns-remote.com", {} }, .{ "dyndns-server.com", {} }, .{ "dyndns-web.com", {} }, .{ "dyndns-wiki.com", {} }, .{ "dyndns-work.com", {} }, .{ "est-a-la-maison.com", {} }, .{ "est-a-la-masion.com", {} }, .{ "est-le-patron.com", {} }, .{ "est-mon-blogueur.com", {} }, .{ "from-ak.com", {} }, .{ "from-al.com", {} }, .{ "from-ar.com", {} }, .{ "from-ca.com", {} }, .{ "from-ct.com", {} }, .{ "from-dc.com", {} }, .{ "from-de.com", {} }, .{ "from-fl.com", {} }, .{ "from-ga.com", {} }, .{ "from-hi.com", {} }, .{ "from-ia.com", {} }, .{ "from-id.com", {} }, .{ "from-il.com", {} }, .{ "from-in.com", {} }, .{ "from-ks.com", {} }, .{ "from-ky.com", {} }, .{ "from-ma.com", {} }, .{ "from-md.com", {} }, .{ "from-mi.com", {} }, .{ "from-mn.com", {} }, .{ "from-mo.com", {} }, .{ "from-ms.com", {} }, .{ "from-mt.com", {} }, .{ "from-nc.com", {} }, .{ "from-nd.com", {} }, .{ "from-ne.com", {} }, .{ "from-nh.com", {} }, .{ "from-nj.com", {} }, .{ "from-nm.com", {} }, .{ "from-nv.com", {} }, .{ "from-oh.com", {} }, .{ "from-ok.com", {} }, .{ "from-or.com", {} }, .{ "from-pa.com", {} }, .{ "from-pr.com", {} }, .{ "from-ri.com", {} }, .{ "from-sc.com", {} }, .{ "from-sd.com", {} }, .{ "from-tn.com", {} }, .{ "from-tx.com", {} }, .{ "from-ut.com", {} }, .{ "from-va.com", {} }, .{ "from-vt.com", {} }, .{ "from-wa.com", {} }, .{ "from-wi.com", {} }, .{ "from-wv.com", {} }, .{ "from-wy.com", {} }, .{ "getmyip.com", {} }, .{ "gotdns.com", {} }, .{ "hobby-site.com", {} }, .{ "homelinux.com", {} }, .{ "homeunix.com", {} }, .{ "iamallama.com", {} }, .{ "is-a-anarchist.com", {} }, .{ "is-a-blogger.com", {} }, .{ "is-a-bookkeeper.com", {} }, .{ "is-a-bulls-fan.com", {} }, .{ "is-a-caterer.com", {} }, .{ "is-a-chef.com", {} }, .{ "is-a-conservative.com", {} }, .{ "is-a-cpa.com", {} }, .{ "is-a-cubicle-slave.com", {} }, .{ "is-a-democrat.com", {} }, .{ "is-a-designer.com", {} }, .{ "is-a-doctor.com", {} }, .{ "is-a-financialadvisor.com", {} }, .{ "is-a-geek.com", {} }, .{ "is-a-green.com", {} }, .{ "is-a-guru.com", {} }, .{ "is-a-hard-worker.com", {} }, .{ "is-a-hunter.com", {} }, .{ "is-a-landscaper.com", {} }, .{ "is-a-lawyer.com", {} }, .{ "is-a-liberal.com", {} }, .{ "is-a-libertarian.com", {} }, .{ "is-a-llama.com", {} }, .{ "is-a-musician.com", {} }, .{ "is-a-nascarfan.com", {} }, .{ "is-a-nurse.com", {} }, .{ "is-a-painter.com", {} }, .{ "is-a-personaltrainer.com", {} }, .{ "is-a-photographer.com", {} }, .{ "is-a-player.com", {} }, .{ "is-a-republican.com", {} }, .{ "is-a-rockstar.com", {} }, .{ "is-a-socialist.com", {} }, .{ "is-a-student.com", {} }, .{ "is-a-teacher.com", {} }, .{ "is-a-techie.com", {} }, .{ "is-a-therapist.com", {} }, .{ "is-an-accountant.com", {} }, .{ "is-an-actor.com", {} }, .{ "is-an-actress.com", {} }, .{ "is-an-anarchist.com", {} }, .{ "is-an-artist.com", {} }, .{ "is-an-engineer.com", {} }, .{ "is-an-entertainer.com", {} }, .{ "is-certified.com", {} }, .{ "is-gone.com", {} }, .{ "is-into-anime.com", {} }, .{ "is-into-cars.com", {} }, .{ "is-into-cartoons.com", {} }, .{ "is-into-games.com", {} }, .{ "is-leet.com", {} }, .{ "is-not-certified.com", {} }, .{ "is-slick.com", {} }, .{ "is-uberleet.com", {} }, .{ "is-with-theband.com", {} }, .{ "isa-geek.com", {} }, .{ "isa-hockeynut.com", {} }, .{ "issmarterthanyou.com", {} }, .{ "likes-pie.com", {} }, .{ "likescandy.com", {} }, .{ "neat-url.com", {} }, .{ "saves-the-whales.com", {} }, .{ "selfip.com", {} }, .{ "sells-for-less.com", {} }, .{ "sells-for-u.com", {} }, .{ "servebbs.com", {} }, .{ "simple-url.com", {} }, .{ "space-to-rent.com", {} }, .{ "teaches-yoga.com", {} }, .{ "writesthisblog.com", {} }, .{ "ath.cx", {} }, .{ "fuettertdasnetz.de", {} }, .{ "isteingeek.de", {} }, .{ "istmein.de", {} }, .{ "lebtimnetz.de", {} }, .{ "leitungsen.de", {} }, .{ "traeumtgerade.de", {} }, .{ "barrel-of-knowledge.info", {} }, .{ "barrell-of-knowledge.info", {} }, .{ "dyndns.info", {} }, .{ "for-our.info", {} }, .{ "groks-the.info", {} }, .{ "groks-this.info", {} }, .{ "here-for-more.info", {} }, .{ "knowsitall.info", {} }, .{ "selfip.info", {} }, .{ "webhop.info", {} }, .{ "forgot.her.name", {} }, .{ "forgot.his.name", {} }, .{ "at-band-camp.net", {} }, .{ "blogdns.net", {} }, .{ "broke-it.net", {} }, .{ "buyshouses.net", {} }, .{ "dnsalias.net", {} }, .{ "dnsdojo.net", {} }, .{ "does-it.net", {} }, .{ "dontexist.net", {} }, .{ "dynalias.net", {} }, .{ "dynathome.net", {} }, .{ "endofinternet.net", {} }, .{ "from-az.net", {} }, .{ "from-co.net", {} }, .{ "from-la.net", {} }, .{ "from-ny.net", {} }, .{ "gets-it.net", {} }, .{ "ham-radio-op.net", {} }, .{ "homeftp.net", {} }, .{ "homeip.net", {} }, .{ "homelinux.net", {} }, .{ "homeunix.net", {} }, .{ "in-the-band.net", {} }, .{ "is-a-chef.net", {} }, .{ "is-a-geek.net", {} }, .{ "isa-geek.net", {} }, .{ "kicks-ass.net", {} }, .{ "office-on-the.net", {} }, .{ "podzone.net", {} }, .{ "scrapper-site.net", {} }, .{ "selfip.net", {} }, .{ "sells-it.net", {} }, .{ "servebbs.net", {} }, .{ "serveftp.net", {} }, .{ "thruhere.net", {} }, .{ "webhop.net", {} }, .{ "merseine.nu", {} }, .{ "mine.nu", {} }, .{ "shacknet.nu", {} }, .{ "blogdns.org", {} }, .{ "blogsite.org", {} }, .{ "boldlygoingnowhere.org", {} }, .{ "dnsalias.org", {} }, .{ "dnsdojo.org", {} }, .{ "doesntexist.org", {} }, .{ "dontexist.org", {} }, .{ "doomdns.org", {} }, .{ "dvrdns.org", {} }, .{ "dynalias.org", {} }, .{ "dyndns.org", {} }, .{ "go.dyndns.org", {} }, .{ "home.dyndns.org", {} }, .{ "endofinternet.org", {} }, .{ "endoftheinternet.org", {} }, .{ "from-me.org", {} }, .{ "game-host.org", {} }, .{ "gotdns.org", {} }, .{ "hobby-site.org", {} }, .{ "homedns.org", {} }, .{ "homeftp.org", {} }, .{ "homelinux.org", {} }, .{ "homeunix.org", {} }, .{ "is-a-bruinsfan.org", {} }, .{ "is-a-candidate.org", {} }, .{ "is-a-celticsfan.org", {} }, .{ "is-a-chef.org", {} }, .{ "is-a-geek.org", {} }, .{ "is-a-knight.org", {} }, .{ "is-a-linux-user.org", {} }, .{ "is-a-patsfan.org", {} }, .{ "is-a-soxfan.org", {} }, .{ "is-found.org", {} }, .{ "is-lost.org", {} }, .{ "is-saved.org", {} }, .{ "is-very-bad.org", {} }, .{ "is-very-evil.org", {} }, .{ "is-very-good.org", {} }, .{ "is-very-nice.org", {} }, .{ "is-very-sweet.org", {} }, .{ "isa-geek.org", {} }, .{ "kicks-ass.org", {} }, .{ "misconfused.org", {} }, .{ "podzone.org", {} }, .{ "readmyblog.org", {} }, .{ "selfip.org", {} }, .{ "sellsyourhome.org", {} }, .{ "servebbs.org", {} }, .{ "serveftp.org", {} }, .{ "servegame.org", {} }, .{ "stuff-4-sale.org", {} }, .{ "webhop.org", {} }, .{ "better-than.tv", {} }, .{ "dyndns.tv", {} }, .{ "on-the-web.tv", {} }, .{ "worse-than.tv", {} }, .{ "is-by.us", {} }, .{ "land-4-sale.us", {} }, .{ "stuff-4-sale.us", {} }, .{ "dyndns.ws", {} }, .{ "mypets.ws", {} }, .{ "ddnsfree.com", {} }, .{ "ddnsgeek.com", {} }, .{ "giize.com", {} }, .{ "gleeze.com", {} }, .{ "kozow.com", {} }, .{ "loseyourip.com", {} }, .{ "ooguy.com", {} }, .{ "theworkpc.com", {} }, .{ "casacam.net", {} }, .{ "dynu.net", {} }, .{ "accesscam.org", {} }, .{ "camdvr.org", {} }, .{ "freeddns.org", {} }, .{ "mywire.org", {} }, .{ "webredirect.org", {} }, .{ "myddns.rocks", {} }, .{ "dynv6.net", {} }, .{ "e4.cz", {} }, .{ "easypanel.app", {} }, .{ "easypanel.host", {} }, .{ "*.ewp.live", {} }, .{ "twmail.cc", {} }, .{ "twmail.net", {} }, .{ "twmail.org", {} }, .{ "mymailer.com.tw", {} }, .{ "url.tw", {} }, .{ "at.emf.camp", {} }, .{ "rt.ht", {} }, .{ "elementor.cloud", {} }, .{ "elementor.cool", {} }, .{ "emergent.cloud", {} }, .{ "preview.emergentagent.com", {} }, .{ "emergent.host", {} }, .{ "mytuleap.com", {} }, .{ "tuleap-partners.com", {} }, .{ "encr.app", {} }, .{ "frontend.encr.app", {} }, .{ "encoreapi.com", {} }, .{ "lp.dev", {} }, .{ "api.lp.dev", {} }, .{ "objects.lp.dev", {} }, .{ "eu.encoway.cloud", {} }, .{ "eu.org", {} }, .{ "al.eu.org", {} }, .{ "asso.eu.org", {} }, .{ "at.eu.org", {} }, .{ "au.eu.org", {} }, .{ "be.eu.org", {} }, .{ "bg.eu.org", {} }, .{ "ca.eu.org", {} }, .{ "cd.eu.org", {} }, .{ "ch.eu.org", {} }, .{ "cn.eu.org", {} }, .{ "cy.eu.org", {} }, .{ "cz.eu.org", {} }, .{ "de.eu.org", {} }, .{ "dk.eu.org", {} }, .{ "edu.eu.org", {} }, .{ "ee.eu.org", {} }, .{ "es.eu.org", {} }, .{ "fi.eu.org", {} }, .{ "fr.eu.org", {} }, .{ "gr.eu.org", {} }, .{ "hr.eu.org", {} }, .{ "hu.eu.org", {} }, .{ "ie.eu.org", {} }, .{ "il.eu.org", {} }, .{ "in.eu.org", {} }, .{ "int.eu.org", {} }, .{ "is.eu.org", {} }, .{ "it.eu.org", {} }, .{ "jp.eu.org", {} }, .{ "kr.eu.org", {} }, .{ "lt.eu.org", {} }, .{ "lu.eu.org", {} }, .{ "lv.eu.org", {} }, .{ "me.eu.org", {} }, .{ "mk.eu.org", {} }, .{ "mt.eu.org", {} }, .{ "my.eu.org", {} }, .{ "net.eu.org", {} }, .{ "ng.eu.org", {} }, .{ "nl.eu.org", {} }, .{ "no.eu.org", {} }, .{ "nz.eu.org", {} }, .{ "pl.eu.org", {} }, .{ "pt.eu.org", {} }, .{ "ro.eu.org", {} }, .{ "ru.eu.org", {} }, .{ "se.eu.org", {} }, .{ "si.eu.org", {} }, .{ "sk.eu.org", {} }, .{ "tr.eu.org", {} }, .{ "uk.eu.org", {} }, .{ "us.eu.org", {} }, .{ "eurodir.ru", {} }, .{ "eu-1.evennode.com", {} }, .{ "eu-2.evennode.com", {} }, .{ "eu-3.evennode.com", {} }, .{ "eu-4.evennode.com", {} }, .{ "us-1.evennode.com", {} }, .{ "us-2.evennode.com", {} }, .{ "us-3.evennode.com", {} }, .{ "us-4.evennode.com", {} }, .{ "relay.evervault.app", {} }, .{ "relay.evervault.dev", {} }, .{ "expo.app", {} }, .{ "staging.expo.app", {} }, .{ "onfabrica.com", {} }, .{ "ru.net", {} }, .{ "adygeya.ru", {} }, .{ "bashkiria.ru", {} }, .{ "bir.ru", {} }, .{ "cbg.ru", {} }, .{ "com.ru", {} }, .{ "dagestan.ru", {} }, .{ "grozny.ru", {} }, .{ "kalmykia.ru", {} }, .{ "kustanai.ru", {} }, .{ "marine.ru", {} }, .{ "mordovia.ru", {} }, .{ "msk.ru", {} }, .{ "mytis.ru", {} }, .{ "nalchik.ru", {} }, .{ "nov.ru", {} }, .{ "pyatigorsk.ru", {} }, .{ "spb.ru", {} }, .{ "vladikavkaz.ru", {} }, .{ "vladimir.ru", {} }, .{ "abkhazia.su", {} }, .{ "adygeya.su", {} }, .{ "aktyubinsk.su", {} }, .{ "arkhangelsk.su", {} }, .{ "armenia.su", {} }, .{ "ashgabad.su", {} }, .{ "azerbaijan.su", {} }, .{ "balashov.su", {} }, .{ "bashkiria.su", {} }, .{ "bryansk.su", {} }, .{ "bukhara.su", {} }, .{ "chimkent.su", {} }, .{ "dagestan.su", {} }, .{ "east-kazakhstan.su", {} }, .{ "exnet.su", {} }, .{ "georgia.su", {} }, .{ "grozny.su", {} }, .{ "ivanovo.su", {} }, .{ "jambyl.su", {} }, .{ "kalmykia.su", {} }, .{ "kaluga.su", {} }, .{ "karacol.su", {} }, .{ "karaganda.su", {} }, .{ "karelia.su", {} }, .{ "khakassia.su", {} }, .{ "krasnodar.su", {} }, .{ "kurgan.su", {} }, .{ "kustanai.su", {} }, .{ "lenug.su", {} }, .{ "mangyshlak.su", {} }, .{ "mordovia.su", {} }, .{ "msk.su", {} }, .{ "murmansk.su", {} }, .{ "nalchik.su", {} }, .{ "navoi.su", {} }, .{ "north-kazakhstan.su", {} }, .{ "nov.su", {} }, .{ "obninsk.su", {} }, .{ "penza.su", {} }, .{ "pokrovsk.su", {} }, .{ "sochi.su", {} }, .{ "spb.su", {} }, .{ "tashkent.su", {} }, .{ "termez.su", {} }, .{ "togliatti.su", {} }, .{ "troitsk.su", {} }, .{ "tselinograd.su", {} }, .{ "tula.su", {} }, .{ "tuva.su", {} }, .{ "vladikavkaz.su", {} }, .{ "vladimir.su", {} }, .{ "vologda.su", {} }, .{ "channelsdvr.net", {} }, .{ "u.channelsdvr.net", {} }, .{ "edgecompute.app", {} }, .{ "fastly-edge.com", {} }, .{ "fastly-terrarium.com", {} }, .{ "freetls.fastly.net", {} }, .{ "map.fastly.net", {} }, .{ "a.prod.fastly.net", {} }, .{ "global.prod.fastly.net", {} }, .{ "a.ssl.fastly.net", {} }, .{ "b.ssl.fastly.net", {} }, .{ "global.ssl.fastly.net", {} }, .{ "fastlylb.net", {} }, .{ "map.fastlylb.net", {} }, .{ "*.user.fm", {} }, .{ "fastvps-server.com", {} }, .{ "fastvps.host", {} }, .{ "myfast.host", {} }, .{ "fastvps.site", {} }, .{ "myfast.space", {} }, .{ "conn.uk", {} }, .{ "copro.uk", {} }, .{ "hosp.uk", {} }, .{ "fedorainfracloud.org", {} }, .{ "fedorapeople.org", {} }, .{ "cloud.fedoraproject.org", {} }, .{ "app.os.fedoraproject.org", {} }, .{ "app.os.stg.fedoraproject.org", {} }, .{ "mydobiss.com", {} }, .{ "fh-muenster.io", {} }, .{ "figma.site", {} }, .{ "figma-gov.site", {} }, .{ "preview.site", {} }, .{ "filegear.me", {} }, .{ "firebaseapp.com", {} }, .{ "fldrv.com", {} }, .{ "on-fleek.app", {} }, .{ "flutterflow.app", {} }, .{ "sprites.app", {} }, .{ "fly.dev", {} }, .{ "e2b.app", {} }, .{ "framer.ai", {} }, .{ "framer.app", {} }, .{ "framercanvas.com", {} }, .{ "framer.media", {} }, .{ "framer.photos", {} }, .{ "framer.website", {} }, .{ "framer.wiki", {} }, .{ "*.0e.vc", {} }, .{ "freebox-os.com", {} }, .{ "freeboxos.com", {} }, .{ "fbx-os.fr", {} }, .{ "fbxos.fr", {} }, .{ "freebox-os.fr", {} }, .{ "freeboxos.fr", {} }, .{ "freedesktop.org", {} }, .{ "freemyip.com", {} }, .{ "*.frusky.de", {} }, .{ "wien.funkfeuer.at", {} }, .{ "daemon.asia", {} }, .{ "dix.asia", {} }, .{ "mydns.bz", {} }, .{ "0am.jp", {} }, .{ "0g0.jp", {} }, .{ "0j0.jp", {} }, .{ "0t0.jp", {} }, .{ "mydns.jp", {} }, .{ "pgw.jp", {} }, .{ "wjg.jp", {} }, .{ "keyword-on.net", {} }, .{ "live-on.net", {} }, .{ "server-on.net", {} }, .{ "mydns.tw", {} }, .{ "mydns.vc", {} }, .{ "*.futurecms.at", {} }, .{ "*.ex.futurecms.at", {} }, .{ "*.in.futurecms.at", {} }, .{ "futurehosting.at", {} }, .{ "futuremailing.at", {} }, .{ "*.ex.ortsinfo.at", {} }, .{ "*.kunden.ortsinfo.at", {} }, .{ "*.statics.cloud", {} }, .{ "gadget.app", {} }, .{ "gadget.host", {} }, .{ "aliases121.com", {} }, .{ "campaign.gov.uk", {} }, .{ "service.gov.uk", {} }, .{ "independent-commission.uk", {} }, .{ "independent-inquest.uk", {} }, .{ "independent-inquiry.uk", {} }, .{ "independent-panel.uk", {} }, .{ "independent-review.uk", {} }, .{ "public-inquiry.uk", {} }, .{ "royal-commission.uk", {} }, .{ "gehirn.ne.jp", {} }, .{ "usercontent.jp", {} }, .{ "gentapps.com", {} }, .{ "gentlentapis.com", {} }, .{ "cdn-edges.net", {} }, .{ "gsj.bz", {} }, .{ "gitbook.io", {} }, .{ "github.app", {} }, .{ "githubusercontent.com", {} }, .{ "githubpreview.dev", {} }, .{ "github.io", {} }, .{ "gitlab.io", {} }, .{ "gitapp.si", {} }, .{ "gitpage.si", {} }, .{ "nog.community", {} }, .{ "co.ro", {} }, .{ "shop.ro", {} }, .{ "lolipop.io", {} }, .{ "angry.jp", {} }, .{ "babyblue.jp", {} }, .{ "babymilk.jp", {} }, .{ "backdrop.jp", {} }, .{ "bambina.jp", {} }, .{ "bitter.jp", {} }, .{ "blush.jp", {} }, .{ "boo.jp", {} }, .{ "boy.jp", {} }, .{ "boyfriend.jp", {} }, .{ "but.jp", {} }, .{ "candypop.jp", {} }, .{ "capoo.jp", {} }, .{ "catfood.jp", {} }, .{ "cheap.jp", {} }, .{ "chicappa.jp", {} }, .{ "chillout.jp", {} }, .{ "chips.jp", {} }, .{ "chowder.jp", {} }, .{ "chu.jp", {} }, .{ "ciao.jp", {} }, .{ "cocotte.jp", {} }, .{ "coolblog.jp", {} }, .{ "cranky.jp", {} }, .{ "cutegirl.jp", {} }, .{ "daa.jp", {} }, .{ "deca.jp", {} }, .{ "deci.jp", {} }, .{ "digick.jp", {} }, .{ "egoism.jp", {} }, .{ "fakefur.jp", {} }, .{ "fem.jp", {} }, .{ "flier.jp", {} }, .{ "floppy.jp", {} }, .{ "fool.jp", {} }, .{ "frenchkiss.jp", {} }, .{ "girlfriend.jp", {} }, .{ "girly.jp", {} }, .{ "gloomy.jp", {} }, .{ "gonna.jp", {} }, .{ "greater.jp", {} }, .{ "hacca.jp", {} }, .{ "heavy.jp", {} }, .{ "her.jp", {} }, .{ "hiho.jp", {} }, .{ "hippy.jp", {} }, .{ "holy.jp", {} }, .{ "hungry.jp", {} }, .{ "icurus.jp", {} }, .{ "itigo.jp", {} }, .{ "jellybean.jp", {} }, .{ "kikirara.jp", {} }, .{ "kill.jp", {} }, .{ "kilo.jp", {} }, .{ "kuron.jp", {} }, .{ "littlestar.jp", {} }, .{ "lolipopmc.jp", {} }, .{ "lolitapunk.jp", {} }, .{ "lomo.jp", {} }, .{ "lovepop.jp", {} }, .{ "lovesick.jp", {} }, .{ "main.jp", {} }, .{ "mods.jp", {} }, .{ "mond.jp", {} }, .{ "mongolian.jp", {} }, .{ "moo.jp", {} }, .{ "namaste.jp", {} }, .{ "nikita.jp", {} }, .{ "nobushi.jp", {} }, .{ "noor.jp", {} }, .{ "oops.jp", {} }, .{ "parallel.jp", {} }, .{ "parasite.jp", {} }, .{ "pecori.jp", {} }, .{ "peewee.jp", {} }, .{ "penne.jp", {} }, .{ "pepper.jp", {} }, .{ "perma.jp", {} }, .{ "pigboat.jp", {} }, .{ "pinoko.jp", {} }, .{ "punyu.jp", {} }, .{ "pupu.jp", {} }, .{ "pussycat.jp", {} }, .{ "pya.jp", {} }, .{ "raindrop.jp", {} }, .{ "readymade.jp", {} }, .{ "sadist.jp", {} }, .{ "schoolbus.jp", {} }, .{ "secret.jp", {} }, .{ "staba.jp", {} }, .{ "stripper.jp", {} }, .{ "sub.jp", {} }, .{ "sunnyday.jp", {} }, .{ "thick.jp", {} }, .{ "tonkotsu.jp", {} }, .{ "under.jp", {} }, .{ "upper.jp", {} }, .{ "velvet.jp", {} }, .{ "verse.jp", {} }, .{ "versus.jp", {} }, .{ "vivian.jp", {} }, .{ "watson.jp", {} }, .{ "weblike.jp", {} }, .{ "whitesnow.jp", {} }, .{ "zombie.jp", {} }, .{ "heteml.net", {} }, .{ "graphic.design", {} }, .{ "goip.de", {} }, .{ "*.hosted.app", {} }, .{ "*.run.app", {} }, .{ "*.mtls.run.app", {} }, .{ "web.app", {} }, .{ "*.0emm.com", {} }, .{ "appspot.com", {} }, .{ "*.r.appspot.com", {} }, .{ "blogspot.com", {} }, .{ "codespot.com", {} }, .{ "googleapis.com", {} }, .{ "googlecode.com", {} }, .{ "pagespeedmobilizer.com", {} }, .{ "withgoogle.com", {} }, .{ "withyoutube.com", {} }, .{ "*.gateway.dev", {} }, .{ "cloud.goog", {} }, .{ "translate.goog", {} }, .{ "*.usercontent.goog", {} }, .{ "cloudfunctions.net", {} }, .{ "goupile.fr", {} }, .{ "pymnt.uk", {} }, .{ "cloudapps.digital", {} }, .{ "london.cloudapps.digital", {} }, .{ "gov.nl", {} }, .{ "grafana-dev.net", {} }, .{ "grayjayleagues.com", {} }, .{ "grebedoc.dev", {} }, .{ "günstigbestellen.de", {} }, .{ "günstigliefern.de", {} }, .{ "gv.uy", {} }, .{ "hackclub.app", {} }, .{ "häkkinen.fi", {} }, .{ "hashbang.sh", {} }, .{ "hasura.app", {} }, .{ "hasura-app.io", {} }, .{ "hatenablog.com", {} }, .{ "hatenadiary.com", {} }, .{ "hateblo.jp", {} }, .{ "hatenablog.jp", {} }, .{ "hatenadiary.jp", {} }, .{ "hatenadiary.org", {} }, .{ "pages.it.hs-heilbronn.de", {} }, .{ "pages-research.it.hs-heilbronn.de", {} }, .{ "heiyu.space", {} }, .{ "helioho.st", {} }, .{ "heliohost.us", {} }, .{ "hepforge.org", {} }, .{ "onhercules.app", {} }, .{ "hercules-app.com", {} }, .{ "hercules-dev.com", {} }, .{ "herokuapp.com", {} }, .{ "heyflow.page", {} }, .{ "heyflow.site", {} }, .{ "ravendb.cloud", {} }, .{ "ravendb.community", {} }, .{ "development.run", {} }, .{ "ravendb.run", {} }, .{ "hidns.co", {} }, .{ "hidns.vip", {} }, .{ "homesklep.pl", {} }, .{ "*.kin.one", {} }, .{ "*.id.pub", {} }, .{ "*.kin.pub", {} }, .{ "hoplix.shop", {} }, .{ "orx.biz", {} }, .{ "biz.ng", {} }, .{ "co.biz.ng", {} }, .{ "dl.biz.ng", {} }, .{ "go.biz.ng", {} }, .{ "lg.biz.ng", {} }, .{ "on.biz.ng", {} }, .{ "col.ng", {} }, .{ "firm.ng", {} }, .{ "gen.ng", {} }, .{ "ltd.ng", {} }, .{ "ngo.ng", {} }, .{ "plc.ng", {} }, .{ "hostyhosting.io", {} }, .{ "hf.space", {} }, .{ "static.hf.space", {} }, .{ "hypernode.io", {} }, .{ "iobb.net", {} }, .{ "co.cz", {} }, .{ "*.moonscale.io", {} }, .{ "moonscale.net", {} }, .{ "gr.com", {} }, .{ "iki.fi", {} }, .{ "ibxos.it", {} }, .{ "iliadboxos.it", {} }, .{ "imagine-proxy.work", {} }, .{ "smushcdn.com", {} }, .{ "wphostedmail.com", {} }, .{ "wpmucdn.com", {} }, .{ "tempurl.host", {} }, .{ "wpmudev.host", {} }, .{ "dyn-berlin.de", {} }, .{ "in-berlin.de", {} }, .{ "in-brb.de", {} }, .{ "in-butter.de", {} }, .{ "in-dsl.de", {} }, .{ "in-vpn.de", {} }, .{ "in-dsl.net", {} }, .{ "in-vpn.net", {} }, .{ "in-dsl.org", {} }, .{ "in-vpn.org", {} }, .{ "oninferno.net", {} }, .{ "biz.at", {} }, .{ "info.at", {} }, .{ "info.cx", {} }, .{ "ac.leg.br", {} }, .{ "al.leg.br", {} }, .{ "am.leg.br", {} }, .{ "ap.leg.br", {} }, .{ "ba.leg.br", {} }, .{ "ce.leg.br", {} }, .{ "df.leg.br", {} }, .{ "es.leg.br", {} }, .{ "go.leg.br", {} }, .{ "ma.leg.br", {} }, .{ "mg.leg.br", {} }, .{ "ms.leg.br", {} }, .{ "mt.leg.br", {} }, .{ "pa.leg.br", {} }, .{ "pb.leg.br", {} }, .{ "pe.leg.br", {} }, .{ "pi.leg.br", {} }, .{ "pr.leg.br", {} }, .{ "rj.leg.br", {} }, .{ "rn.leg.br", {} }, .{ "ro.leg.br", {} }, .{ "rr.leg.br", {} }, .{ "rs.leg.br", {} }, .{ "sc.leg.br", {} }, .{ "se.leg.br", {} }, .{ "sp.leg.br", {} }, .{ "to.leg.br", {} }, .{ "pixolino.com", {} }, .{ "na4u.ru", {} }, .{ "botdash.app", {} }, .{ "botdash.dev", {} }, .{ "botdash.gg", {} }, .{ "botdash.net", {} }, .{ "botda.sh", {} }, .{ "botdash.xyz", {} }, .{ "apps-1and1.com", {} }, .{ "live-website.com", {} }, .{ "webspace-host.com", {} }, .{ "apps-1and1.net", {} }, .{ "websitebuilder.online", {} }, .{ "app-ionos.space", {} }, .{ "iopsys.se", {} }, .{ "*.inbrowser.dev", {} }, .{ "*.dweb.link", {} }, .{ "*.inbrowser.link", {} }, .{ "ipifony.net", {} }, .{ "ir.md", {} }, .{ "is-a-good.dev", {} }, .{ "iservschule.de", {} }, .{ "mein-iserv.de", {} }, .{ "schuldock.de", {} }, .{ "schulplattform.de", {} }, .{ "schulserver.de", {} }, .{ "test-iserv.de", {} }, .{ "iserv.dev", {} }, .{ "iserv.host", {} }, .{ "ispmanager.name", {} }, .{ "mel.cloudlets.com.au", {} }, .{ "cloud.interhostsolutions.be", {} }, .{ "alp1.ae.flow.ch", {} }, .{ "appengine.flow.ch", {} }, .{ "es-1.axarnet.cloud", {} }, .{ "diadem.cloud", {} }, .{ "vip.jelastic.cloud", {} }, .{ "jele.cloud", {} }, .{ "it1.eur.aruba.jenv-aruba.cloud", {} }, .{ "it1.jenv-aruba.cloud", {} }, .{ "keliweb.cloud", {} }, .{ "cs.keliweb.cloud", {} }, .{ "oxa.cloud", {} }, .{ "tn.oxa.cloud", {} }, .{ "uk.oxa.cloud", {} }, .{ "primetel.cloud", {} }, .{ "uk.primetel.cloud", {} }, .{ "ca.reclaim.cloud", {} }, .{ "uk.reclaim.cloud", {} }, .{ "us.reclaim.cloud", {} }, .{ "ch.trendhosting.cloud", {} }, .{ "de.trendhosting.cloud", {} }, .{ "jele.club", {} }, .{ "dopaas.com", {} }, .{ "paas.hosted-by-previder.com", {} }, .{ "rag-cloud.hosteur.com", {} }, .{ "rag-cloud-ch.hosteur.com", {} }, .{ "jcloud.ik-server.com", {} }, .{ "jcloud-ver-jpc.ik-server.com", {} }, .{ "demo.jelastic.com", {} }, .{ "paas.massivegrid.com", {} }, .{ "jed.wafaicloud.com", {} }, .{ "ryd.wafaicloud.com", {} }, .{ "j.scaleforce.com.cy", {} }, .{ "jelastic.dogado.eu", {} }, .{ "fi.cloudplatform.fi", {} }, .{ "demo.datacenter.fi", {} }, .{ "paas.datacenter.fi", {} }, .{ "jele.host", {} }, .{ "mircloud.host", {} }, .{ "paas.beebyte.io", {} }, .{ "sekd1.beebyteapp.io", {} }, .{ "jele.io", {} }, .{ "jc.neen.it", {} }, .{ "jcloud.kz", {} }, .{ "cloudjiffy.net", {} }, .{ "fra1-de.cloudjiffy.net", {} }, .{ "west1-us.cloudjiffy.net", {} }, .{ "jls-sto1.elastx.net", {} }, .{ "jls-sto2.elastx.net", {} }, .{ "jls-sto3.elastx.net", {} }, .{ "fr-1.paas.massivegrid.net", {} }, .{ "lon-1.paas.massivegrid.net", {} }, .{ "lon-2.paas.massivegrid.net", {} }, .{ "ny-1.paas.massivegrid.net", {} }, .{ "ny-2.paas.massivegrid.net", {} }, .{ "sg-1.paas.massivegrid.net", {} }, .{ "jelastic.saveincloud.net", {} }, .{ "nordeste-idc.saveincloud.net", {} }, .{ "j.scaleforce.net", {} }, .{ "sdscloud.pl", {} }, .{ "unicloud.pl", {} }, .{ "mircloud.ru", {} }, .{ "enscaled.sg", {} }, .{ "jele.site", {} }, .{ "jelastic.team", {} }, .{ "orangecloud.tn", {} }, .{ "j.layershift.co.uk", {} }, .{ "phx.enscaled.us", {} }, .{ "mircloud.us", {} }, .{ "myjino.ru", {} }, .{ "*.hosting.myjino.ru", {} }, .{ "*.landing.myjino.ru", {} }, .{ "*.spectrum.myjino.ru", {} }, .{ "*.vps.myjino.ru", {} }, .{ "jote.cloud", {} }, .{ "jotelulu.cloud", {} }, .{ "eu1-plenit.com", {} }, .{ "la1-plenit.com", {} }, .{ "us1-plenit.com", {} }, .{ "webadorsite.com", {} }, .{ "jouwweb.site", {} }, .{ "*.cns.joyent.com", {} }, .{ "*.triton.zone", {} }, .{ "js.org", {} }, .{ "kaas.gg", {} }, .{ "khplay.nl", {} }, .{ "kapsi.fi", {} }, .{ "ezproxy.kuleuven.be", {} }, .{ "kuleuven.cloud", {} }, .{ "ae.kg", {} }, .{ "keymachine.de", {} }, .{ "kiloapps.ai", {} }, .{ "kiloapps.io", {} }, .{ "kinghost.net", {} }, .{ "uni5.net", {} }, .{ "knightpoint.systems", {} }, .{ "koobin.events", {} }, .{ "webthings.io", {} }, .{ "krellian.net", {} }, .{ "oya.to", {} }, .{ "co.de", {} }, .{ "shiptoday.app", {} }, .{ "shiptoday.build", {} }, .{ "laravel.cloud", {} }, .{ "on-forge.com", {} }, .{ "on-vapor.com", {} }, .{ "git-repos.de", {} }, .{ "lcube-server.de", {} }, .{ "svn-repos.de", {} }, .{ "leadpages.co", {} }, .{ "lpages.co", {} }, .{ "lpusercontent.com", {} }, .{ "leapcell.app", {} }, .{ "leapcell.dev", {} }, .{ "leapcell.online", {} }, .{ "liara.run", {} }, .{ "iran.liara.run", {} }, .{ "libp2p.direct", {} }, .{ "runcontainers.dev", {} }, .{ "co.business", {} }, .{ "co.education", {} }, .{ "co.events", {} }, .{ "co.financial", {} }, .{ "co.network", {} }, .{ "co.place", {} }, .{ "co.technology", {} }, .{ "linkyard-cloud.ch", {} }, .{ "linkyard.cloud", {} }, .{ "members.linode.com", {} }, .{ "*.nodebalancer.linode.com", {} }, .{ "*.linodeobjects.com", {} }, .{ "ip.linodeusercontent.com", {} }, .{ "we.bs", {} }, .{ "filegear-sg.me", {} }, .{ "ggff.net", {} }, .{ "*.user.localcert.dev", {} }, .{ "localtonet.com", {} }, .{ "*.localto.net", {} }, .{ "lodz.pl", {} }, .{ "pabianice.pl", {} }, .{ "plock.pl", {} }, .{ "sieradz.pl", {} }, .{ "skierniewice.pl", {} }, .{ "zgierz.pl", {} }, .{ "loginline.app", {} }, .{ "loginline.dev", {} }, .{ "loginline.io", {} }, .{ "loginline.services", {} }, .{ "loginline.site", {} }, .{ "lohmus.me", {} }, .{ "lovable.app", {} }, .{ "lovableproject.com", {} }, .{ "lovable.run", {} }, .{ "lovable.sh", {} }, .{ "krasnik.pl", {} }, .{ "leczna.pl", {} }, .{ "lubartow.pl", {} }, .{ "lublin.pl", {} }, .{ "poniatowa.pl", {} }, .{ "swidnik.pl", {} }, .{ "glug.org.uk", {} }, .{ "lug.org.uk", {} }, .{ "lugs.org.uk", {} }, .{ "barsy.bg", {} }, .{ "barsy.club", {} }, .{ "barsycenter.com", {} }, .{ "barsyonline.com", {} }, .{ "barsy.de", {} }, .{ "barsy.dev", {} }, .{ "barsy.eu", {} }, .{ "barsy.gr", {} }, .{ "barsy.in", {} }, .{ "barsy.info", {} }, .{ "barsy.io", {} }, .{ "barsy.me", {} }, .{ "barsy.menu", {} }, .{ "barsyonline.menu", {} }, .{ "barsy.mobi", {} }, .{ "barsy.net", {} }, .{ "barsy.online", {} }, .{ "barsy.org", {} }, .{ "barsy.pro", {} }, .{ "barsy.pub", {} }, .{ "barsy.ro", {} }, .{ "barsy.rs", {} }, .{ "barsy.shop", {} }, .{ "barsyonline.shop", {} }, .{ "barsy.site", {} }, .{ "barsy.store", {} }, .{ "barsy.support", {} }, .{ "barsy.uk", {} }, .{ "barsy.co.uk", {} }, .{ "barsyonline.co.uk", {} }, .{ "*.lutrausercontent.com", {} }, .{ "luyani.app", {} }, .{ "luyani.net", {} }, .{ "*.magentosite.cloud", {} }, .{ "magicpatterns.app", {} }, .{ "magicpatternsapp.com", {} }, .{ "hb.cldmail.ru", {} }, .{ "matlab.cloud", {} }, .{ "modelscape.com", {} }, .{ "mwcloudnonprod.com", {} }, .{ "polyspace.com", {} }, .{ "mayfirst.info", {} }, .{ "mayfirst.org", {} }, .{ "mazeplay.com", {} }, .{ "mcdir.me", {} }, .{ "mcdir.ru", {} }, .{ "vps.mcdir.ru", {} }, .{ "mcpre.ru", {} }, .{ "mediatech.by", {} }, .{ "mediatech.dev", {} }, .{ "hra.health", {} }, .{ "medusajs.app", {} }, .{ "miniserver.com", {} }, .{ "memset.net", {} }, .{ "messerli.app", {} }, .{ "atmeta.com", {} }, .{ "apps.fbsbx.com", {} }, .{ "*.cloud.metacentrum.cz", {} }, .{ "custom.metacentrum.cz", {} }, .{ "flt.cloud.muni.cz", {} }, .{ "usr.cloud.muni.cz", {} }, .{ "meteorapp.com", {} }, .{ "eu.meteorapp.com", {} }, .{ "co.pl", {} }, .{ "*.azurecontainer.io", {} }, .{ "azure-api.net", {} }, .{ "azure-mobile.net", {} }, .{ "azureedge.net", {} }, .{ "azurefd.net", {} }, .{ "azurestaticapps.net", {} }, .{ "1.azurestaticapps.net", {} }, .{ "2.azurestaticapps.net", {} }, .{ "3.azurestaticapps.net", {} }, .{ "4.azurestaticapps.net", {} }, .{ "5.azurestaticapps.net", {} }, .{ "6.azurestaticapps.net", {} }, .{ "7.azurestaticapps.net", {} }, .{ "centralus.azurestaticapps.net", {} }, .{ "eastasia.azurestaticapps.net", {} }, .{ "eastus2.azurestaticapps.net", {} }, .{ "westeurope.azurestaticapps.net", {} }, .{ "westus2.azurestaticapps.net", {} }, .{ "azurewebsites.net", {} }, .{ "cloudapp.net", {} }, .{ "trafficmanager.net", {} }, .{ "servicebus.usgovcloudapi.net", {} }, .{ "usgovcloudapp.net", {} }, .{ "blob.core.windows.net", {} }, .{ "servicebus.windows.net", {} }, .{ "azure-api.us", {} }, .{ "azurewebsites.us", {} }, .{ "routingthecloud.com", {} }, .{ "sn.mynetname.net", {} }, .{ "routingthecloud.net", {} }, .{ "routingthecloud.org", {} }, .{ "same-app.com", {} }, .{ "same-preview.com", {} }, .{ "csx.cc", {} }, .{ "miren.app", {} }, .{ "miren.systems", {} }, .{ "mydbserver.com", {} }, .{ "webspaceconfig.de", {} }, .{ "mittwald.info", {} }, .{ "mittwaldserver.info", {} }, .{ "typo3server.info", {} }, .{ "project.space", {} }, .{ "mocha.app", {} }, .{ "mochausercontent.com", {} }, .{ "mocha-sandbox.dev", {} }, .{ "modx.dev", {} }, .{ "bmoattachments.org", {} }, .{ "net.ru", {} }, .{ "org.ru", {} }, .{ "pp.ru", {} }, .{ "hostedpi.com", {} }, .{ "caracal.mythic-beasts.com", {} }, .{ "customer.mythic-beasts.com", {} }, .{ "fentiger.mythic-beasts.com", {} }, .{ "lynx.mythic-beasts.com", {} }, .{ "ocelot.mythic-beasts.com", {} }, .{ "oncilla.mythic-beasts.com", {} }, .{ "onza.mythic-beasts.com", {} }, .{ "sphinx.mythic-beasts.com", {} }, .{ "vs.mythic-beasts.com", {} }, .{ "x.mythic-beasts.com", {} }, .{ "yali.mythic-beasts.com", {} }, .{ "cust.retrosnub.co.uk", {} }, .{ "ui.nabu.casa", {} }, .{ "needle.run", {} }, .{ "co.site", {} }, .{ "cloud.nospamproxy.com", {} }, .{ "o365.cloud.nospamproxy.com", {} }, .{ "netlib.re", {} }, .{ "netlify.app", {} }, .{ "4u.com", {} }, .{ "nfshost.com", {} }, .{ "ipfs.nftstorage.link", {} }, .{ "ngo.us", {} }, .{ "ngrok.app", {} }, .{ "ngrok-free.app", {} }, .{ "ngrok.dev", {} }, .{ "ngrok-free.dev", {} }, .{ "ngrok.io", {} }, .{ "ap.ngrok.io", {} }, .{ "au.ngrok.io", {} }, .{ "eu.ngrok.io", {} }, .{ "in.ngrok.io", {} }, .{ "jp.ngrok.io", {} }, .{ "sa.ngrok.io", {} }, .{ "us.ngrok.io", {} }, .{ "ngrok.pizza", {} }, .{ "ngrok.pro", {} }, .{ "torun.pl", {} }, .{ "nh-serv.co.uk", {} }, .{ "nimsite.uk", {} }, .{ "mmafan.biz", {} }, .{ "myftp.biz", {} }, .{ "no-ip.biz", {} }, .{ "no-ip.ca", {} }, .{ "fantasyleague.cc", {} }, .{ "gotdns.ch", {} }, .{ "3utilities.com", {} }, .{ "blogsyte.com", {} }, .{ "ciscofreak.com", {} }, .{ "damnserver.com", {} }, .{ "ddnsking.com", {} }, .{ "ditchyourip.com", {} }, .{ "dnsiskinky.com", {} }, .{ "dynns.com", {} }, .{ "geekgalaxy.com", {} }, .{ "health-carereform.com", {} }, .{ "homesecuritymac.com", {} }, .{ "homesecuritypc.com", {} }, .{ "myactivedirectory.com", {} }, .{ "mysecuritycamera.com", {} }, .{ "myvnc.com", {} }, .{ "net-freaks.com", {} }, .{ "onthewifi.com", {} }, .{ "point2this.com", {} }, .{ "quicksytes.com", {} }, .{ "securitytactics.com", {} }, .{ "servebeer.com", {} }, .{ "servecounterstrike.com", {} }, .{ "serveexchange.com", {} }, .{ "serveftp.com", {} }, .{ "servegame.com", {} }, .{ "servehalflife.com", {} }, .{ "servehttp.com", {} }, .{ "servehumour.com", {} }, .{ "serveirc.com", {} }, .{ "servemp3.com", {} }, .{ "servep2p.com", {} }, .{ "servepics.com", {} }, .{ "servequake.com", {} }, .{ "servesarcasm.com", {} }, .{ "stufftoread.com", {} }, .{ "unusualperson.com", {} }, .{ "workisboring.com", {} }, .{ "dvrcam.info", {} }, .{ "ilovecollege.info", {} }, .{ "no-ip.info", {} }, .{ "brasilia.me", {} }, .{ "ddns.me", {} }, .{ "dnsfor.me", {} }, .{ "hopto.me", {} }, .{ "loginto.me", {} }, .{ "noip.me", {} }, .{ "webhop.me", {} }, .{ "bounceme.net", {} }, .{ "ddns.net", {} }, .{ "eating-organic.net", {} }, .{ "mydissent.net", {} }, .{ "myeffect.net", {} }, .{ "mymediapc.net", {} }, .{ "mypsx.net", {} }, .{ "mysecuritycamera.net", {} }, .{ "nhlfan.net", {} }, .{ "no-ip.net", {} }, .{ "pgafan.net", {} }, .{ "privatizehealthinsurance.net", {} }, .{ "redirectme.net", {} }, .{ "serveblog.net", {} }, .{ "serveminecraft.net", {} }, .{ "sytes.net", {} }, .{ "cable-modem.org", {} }, .{ "collegefan.org", {} }, .{ "couchpotatofries.org", {} }, .{ "hopto.org", {} }, .{ "mlbfan.org", {} }, .{ "myftp.org", {} }, .{ "mysecuritycamera.org", {} }, .{ "nflfan.org", {} }, .{ "no-ip.org", {} }, .{ "read-books.org", {} }, .{ "ufcfan.org", {} }, .{ "zapto.org", {} }, .{ "no-ip.co.uk", {} }, .{ "golffan.us", {} }, .{ "noip.us", {} }, .{ "pointto.us", {} }, .{ "stage.nodeart.io", {} }, .{ "*.developer.app", {} }, .{ "noop.app", {} }, .{ "*.northflank.app", {} }, .{ "*.build.run", {} }, .{ "*.code.run", {} }, .{ "*.database.run", {} }, .{ "*.migration.run", {} }, .{ "noticeable.news", {} }, .{ "notion.site", {} }, .{ "dnsking.ch", {} }, .{ "mypi.co", {} }, .{ "myiphost.com", {} }, .{ "forumz.info", {} }, .{ "soundcast.me", {} }, .{ "tcp4.me", {} }, .{ "dnsup.net", {} }, .{ "hicam.net", {} }, .{ "now-dns.net", {} }, .{ "ownip.net", {} }, .{ "vpndns.net", {} }, .{ "dynserv.org", {} }, .{ "now-dns.org", {} }, .{ "x443.pw", {} }, .{ "ntdll.top", {} }, .{ "freeddns.us", {} }, .{ "nsupdate.info", {} }, .{ "nerdpol.ovh", {} }, .{ "prvcy.page", {} }, .{ "observablehq.cloud", {} }, .{ "static.observableusercontent.com", {} }, .{ "omg.lol", {} }, .{ "cloudycluster.net", {} }, .{ "omniwe.site", {} }, .{ "123webseite.at", {} }, .{ "123website.be", {} }, .{ "simplesite.com.br", {} }, .{ "123website.ch", {} }, .{ "simplesite.com", {} }, .{ "123webseite.de", {} }, .{ "123hjemmeside.dk", {} }, .{ "123miweb.es", {} }, .{ "123kotisivu.fi", {} }, .{ "123siteweb.fr", {} }, .{ "simplesite.gr", {} }, .{ "123homepage.it", {} }, .{ "123website.lu", {} }, .{ "123website.nl", {} }, .{ "123hjemmeside.no", {} }, .{ "service.one", {} }, .{ "website.one", {} }, .{ "simplesite.pl", {} }, .{ "123paginaweb.pt", {} }, .{ "123minsida.se", {} }, .{ "onid.ca", {} }, .{ "is-a-fullstack.dev", {} }, .{ "is-cool.dev", {} }, .{ "is-not-a.dev", {} }, .{ "localplayer.dev", {} }, .{ "is-local.org", {} }, .{ "opensocial.site", {} }, .{ "*.oaiusercontent.com", {} }, .{ "opencraft.hosting", {} }, .{ "16-b.it", {} }, .{ "32-b.it", {} }, .{ "64-b.it", {} }, .{ "orsites.com", {} }, .{ "operaunite.com", {} }, .{ "*.customer-oci.com", {} }, .{ "*.oci.customer-oci.com", {} }, .{ "*.ocp.customer-oci.com", {} }, .{ "*.ocs.customer-oci.com", {} }, .{ "*.oraclecloudapps.com", {} }, .{ "*.oraclegovcloudapps.com", {} }, .{ "*.oraclegovcloudapps.uk", {} }, .{ "tech.orange", {} }, .{ "can.re", {} }, .{ "authgear-staging.com", {} }, .{ "authgearapps.com", {} }, .{ "outsystemscloud.com", {} }, .{ "*.hosting.ovh.net", {} }, .{ "*.webpaas.ovh.net", {} }, .{ "ownprovider.com", {} }, .{ "own.pm", {} }, .{ "*.owo.codes", {} }, .{ "ox.rs", {} }, .{ "oy.lc", {} }, .{ "pgfog.com", {} }, .{ "pagexl.com", {} }, .{ "gotpantheon.com", {} }, .{ "pantheonsite.io", {} }, .{ "*.paywhirl.com", {} }, .{ "*.xmit.co", {} }, .{ "xmit.dev", {} }, .{ "madethis.site", {} }, .{ "srv.us", {} }, .{ "gh.srv.us", {} }, .{ "gl.srv.us", {} }, .{ "mypep.link", {} }, .{ "perspecta.cloud", {} }, .{ "forgeblocks.com", {} }, .{ "id.forgerock.io", {} }, .{ "support.site", {} }, .{ "on-web.fr", {} }, .{ "*.upsun.app", {} }, .{ "upsunapp.com", {} }, .{ "ent.platform.sh", {} }, .{ "eu.platform.sh", {} }, .{ "us.platform.sh", {} }, .{ "*.platformsh.site", {} }, .{ "*.tst.site", {} }, .{ "pley.games", {} }, .{ "onporter.run", {} }, .{ "co.bn", {} }, .{ "postman-echo.com", {} }, .{ "pstmn.io", {} }, .{ "mock.pstmn.io", {} }, .{ "httpbin.org", {} }, .{ "prequalifyme.today", {} }, .{ "xen.prgmr.com", {} }, .{ "priv.at", {} }, .{ "c01.kr", {} }, .{ "eliv-api.kr", {} }, .{ "eliv-cdn.kr", {} }, .{ "eliv-dns.kr", {} }, .{ "mmv.kr", {} }, .{ "vki.kr", {} }, .{ "dev.project-study.com", {} }, .{ "protonet.io", {} }, .{ "platter-app.dev", {} }, .{ "e.id", {} }, .{ "chirurgiens-dentistes-en-france.fr", {} }, .{ "byen.site", {} }, .{ "nyc.mn", {} }, .{ "*.cn.st", {} }, .{ "pubtls.org", {} }, .{ "pythonanywhere.com", {} }, .{ "eu.pythonanywhere.com", {} }, .{ "qa2.com", {} }, .{ "qcx.io", {} }, .{ "*.sys.qcx.io", {} }, .{ "myqnapcloud.cn", {} }, .{ "alpha-myqnapcloud.com", {} }, .{ "dev-myqnapcloud.com", {} }, .{ "mycloudnas.com", {} }, .{ "mynascloud.com", {} }, .{ "myqnapcloud.com", {} }, .{ "qoto.io", {} }, .{ "qualifioapp.com", {} }, .{ "ladesk.com", {} }, .{ "*.qualyhqpartner.com", {} }, .{ "*.qualyhqportal.com", {} }, .{ "qbuser.com", {} }, .{ "*.quipelements.com", {} }, .{ "vapor.cloud", {} }, .{ "vaporcloud.io", {} }, .{ "rackmaze.com", {} }, .{ "rackmaze.net", {} }, .{ "cloudsite.builders", {} }, .{ "myradweb.net", {} }, .{ "servername.us", {} }, .{ "web.in", {} }, .{ "in.net", {} }, .{ "myrdbx.io", {} }, .{ "site.rb-hosting.io", {} }, .{ "up.railway.app", {} }, .{ "*.on-rancher.cloud", {} }, .{ "*.on-k3s.io", {} }, .{ "*.on-rio.io", {} }, .{ "ravpage.co.il", {} }, .{ "readthedocs-hosted.com", {} }, .{ "readthedocs.io", {} }, .{ "rhcloud.com", {} }, .{ "instances.spawn.cc", {} }, .{ "*.clusters.rdpa.co", {} }, .{ "*.srvrless.rdpa.co", {} }, .{ "onrender.com", {} }, .{ "app.render.com", {} }, .{ "replit.app", {} }, .{ "id.replit.app", {} }, .{ "firewalledreplit.co", {} }, .{ "id.firewalledreplit.co", {} }, .{ "repl.co", {} }, .{ "id.repl.co", {} }, .{ "replit.dev", {} }, .{ "archer.replit.dev", {} }, .{ "bones.replit.dev", {} }, .{ "canary.replit.dev", {} }, .{ "global.replit.dev", {} }, .{ "hacker.replit.dev", {} }, .{ "id.replit.dev", {} }, .{ "janeway.replit.dev", {} }, .{ "kim.replit.dev", {} }, .{ "kira.replit.dev", {} }, .{ "kirk.replit.dev", {} }, .{ "odo.replit.dev", {} }, .{ "paris.replit.dev", {} }, .{ "picard.replit.dev", {} }, .{ "pike.replit.dev", {} }, .{ "prerelease.replit.dev", {} }, .{ "reed.replit.dev", {} }, .{ "riker.replit.dev", {} }, .{ "sisko.replit.dev", {} }, .{ "spock.replit.dev", {} }, .{ "staging.replit.dev", {} }, .{ "sulu.replit.dev", {} }, .{ "tarpit.replit.dev", {} }, .{ "teams.replit.dev", {} }, .{ "tucker.replit.dev", {} }, .{ "wesley.replit.dev", {} }, .{ "worf.replit.dev", {} }, .{ "repl.run", {} }, .{ "resindevice.io", {} }, .{ "devices.resinstaging.io", {} }, .{ "hzc.io", {} }, .{ "adimo.co.uk", {} }, .{ "itcouldbewor.se", {} }, .{ "aus.basketball", {} }, .{ "nz.basketball", {} }, .{ "subsc-pay.com", {} }, .{ "subsc-pay.net", {} }, .{ "git-pages.rit.edu", {} }, .{ "rocky.page", {} }, .{ "rub.de", {} }, .{ "ruhr-uni-bochum.de", {} }, .{ "io.noc.ruhr-uni-bochum.de", {} }, .{ "биз.рус", {} }, .{ "ком.рус", {} }, .{ "крым.рус", {} }, .{ "мир.рус", {} }, .{ "мск.рус", {} }, .{ "орг.рус", {} }, .{ "самара.рус", {} }, .{ "сочи.рус", {} }, .{ "спб.рус", {} }, .{ "я.рус", {} }, .{ "ras.ru", {} }, .{ "nyat.app", {} }, .{ "180r.com", {} }, .{ "dojin.com", {} }, .{ "sakuratan.com", {} }, .{ "sakuraweb.com", {} }, .{ "x0.com", {} }, .{ "2-d.jp", {} }, .{ "bona.jp", {} }, .{ "crap.jp", {} }, .{ "daynight.jp", {} }, .{ "eek.jp", {} }, .{ "flop.jp", {} }, .{ "halfmoon.jp", {} }, .{ "jeez.jp", {} }, .{ "matrix.jp", {} }, .{ "mimoza.jp", {} }, .{ "ivory.ne.jp", {} }, .{ "mail-box.ne.jp", {} }, .{ "mints.ne.jp", {} }, .{ "mokuren.ne.jp", {} }, .{ "opal.ne.jp", {} }, .{ "sakura.ne.jp", {} }, .{ "sumomo.ne.jp", {} }, .{ "topaz.ne.jp", {} }, .{ "netgamers.jp", {} }, .{ "nyanta.jp", {} }, .{ "o0o0.jp", {} }, .{ "rdy.jp", {} }, .{ "rgr.jp", {} }, .{ "rulez.jp", {} }, .{ "s3.isk01.sakurastorage.jp", {} }, .{ "s3.isk02.sakurastorage.jp", {} }, .{ "saloon.jp", {} }, .{ "sblo.jp", {} }, .{ "skr.jp", {} }, .{ "tank.jp", {} }, .{ "uh-oh.jp", {} }, .{ "undo.jp", {} }, .{ "rs.webaccel.jp", {} }, .{ "user.webaccel.jp", {} }, .{ "websozai.jp", {} }, .{ "xii.jp", {} }, .{ "squares.net", {} }, .{ "jpn.org", {} }, .{ "kirara.st", {} }, .{ "x0.to", {} }, .{ "from.tv", {} }, .{ "sakura.tv", {} }, .{ "*.builder.code.com", {} }, .{ "*.dev-builder.code.com", {} }, .{ "*.stg-builder.code.com", {} }, .{ "*.001.test.code-builder-stg.platform.salesforce.com", {} }, .{ "*.d.crm.dev", {} }, .{ "*.w.crm.dev", {} }, .{ "*.wa.crm.dev", {} }, .{ "*.wb.crm.dev", {} }, .{ "*.wc.crm.dev", {} }, .{ "*.wd.crm.dev", {} }, .{ "*.we.crm.dev", {} }, .{ "*.wf.crm.dev", {} }, .{ "sandcats.io", {} }, .{ "sav.case", {} }, .{ "logoip.com", {} }, .{ "logoip.de", {} }, .{ "fr-par-1.baremetal.scw.cloud", {} }, .{ "fr-par-2.baremetal.scw.cloud", {} }, .{ "nl-ams-1.baremetal.scw.cloud", {} }, .{ "cockpit.fr-par.scw.cloud", {} }, .{ "ddl.fr-par.scw.cloud", {} }, .{ "dtwh.fr-par.scw.cloud", {} }, .{ "fnc.fr-par.scw.cloud", {} }, .{ "functions.fnc.fr-par.scw.cloud", {} }, .{ "ifr.fr-par.scw.cloud", {} }, .{ "k8s.fr-par.scw.cloud", {} }, .{ "nodes.k8s.fr-par.scw.cloud", {} }, .{ "kafk.fr-par.scw.cloud", {} }, .{ "mgdb.fr-par.scw.cloud", {} }, .{ "rdb.fr-par.scw.cloud", {} }, .{ "s3.fr-par.scw.cloud", {} }, .{ "s3-website.fr-par.scw.cloud", {} }, .{ "scbl.fr-par.scw.cloud", {} }, .{ "whm.fr-par.scw.cloud", {} }, .{ "priv.instances.scw.cloud", {} }, .{ "pub.instances.scw.cloud", {} }, .{ "k8s.scw.cloud", {} }, .{ "cockpit.nl-ams.scw.cloud", {} }, .{ "ddl.nl-ams.scw.cloud", {} }, .{ "dtwh.nl-ams.scw.cloud", {} }, .{ "ifr.nl-ams.scw.cloud", {} }, .{ "k8s.nl-ams.scw.cloud", {} }, .{ "nodes.k8s.nl-ams.scw.cloud", {} }, .{ "kafk.nl-ams.scw.cloud", {} }, .{ "mgdb.nl-ams.scw.cloud", {} }, .{ "rdb.nl-ams.scw.cloud", {} }, .{ "s3.nl-ams.scw.cloud", {} }, .{ "s3-website.nl-ams.scw.cloud", {} }, .{ "scbl.nl-ams.scw.cloud", {} }, .{ "whm.nl-ams.scw.cloud", {} }, .{ "cockpit.pl-waw.scw.cloud", {} }, .{ "ddl.pl-waw.scw.cloud", {} }, .{ "dtwh.pl-waw.scw.cloud", {} }, .{ "ifr.pl-waw.scw.cloud", {} }, .{ "k8s.pl-waw.scw.cloud", {} }, .{ "nodes.k8s.pl-waw.scw.cloud", {} }, .{ "kafk.pl-waw.scw.cloud", {} }, .{ "mgdb.pl-waw.scw.cloud", {} }, .{ "rdb.pl-waw.scw.cloud", {} }, .{ "s3.pl-waw.scw.cloud", {} }, .{ "s3-website.pl-waw.scw.cloud", {} }, .{ "scbl.pl-waw.scw.cloud", {} }, .{ "scalebook.scw.cloud", {} }, .{ "smartlabeling.scw.cloud", {} }, .{ "dedibox.fr", {} }, .{ "schokokeks.net", {} }, .{ "gov.scot", {} }, .{ "service.gov.scot", {} }, .{ "scrysec.com", {} }, .{ "client.scrypted.io", {} }, .{ "firewall-gateway.com", {} }, .{ "firewall-gateway.de", {} }, .{ "my-gateway.de", {} }, .{ "my-router.de", {} }, .{ "spdns.de", {} }, .{ "spdns.eu", {} }, .{ "firewall-gateway.net", {} }, .{ "my-firewall.org", {} }, .{ "myfirewall.org", {} }, .{ "spdns.org", {} }, .{ "seidat.net", {} }, .{ "sellfy.store", {} }, .{ "minisite.ms", {} }, .{ "senseering.net", {} }, .{ "servebolt.cloud", {} }, .{ "biz.ua", {} }, .{ "co.ua", {} }, .{ "pp.ua", {} }, .{ "as.sh.cn", {} }, .{ "sheezy.games", {} }, .{ "myshopblocks.com", {} }, .{ "myshopify.com", {} }, .{ "shopitsite.com", {} }, .{ "shopware.shop", {} }, .{ "shopware.store", {} }, .{ "mo-siemens.io", {} }, .{ "1kapp.com", {} }, .{ "appchizi.com", {} }, .{ "applinzi.com", {} }, .{ "sinaapp.com", {} }, .{ "vipsinaapp.com", {} }, .{ "siteleaf.net", {} }, .{ "small-web.org", {} }, .{ "aeroport.fr", {} }, .{ "avocat.fr", {} }, .{ "chambagri.fr", {} }, .{ "chirurgiens-dentistes.fr", {} }, .{ "experts-comptables.fr", {} }, .{ "medecin.fr", {} }, .{ "notaires.fr", {} }, .{ "pharmacien.fr", {} }, .{ "port.fr", {} }, .{ "veterinaire.fr", {} }, .{ "vp4.me", {} }, .{ "*.snowflake.app", {} }, .{ "*.privatelink.snowflake.app", {} }, .{ "streamlit.app", {} }, .{ "streamlitapp.com", {} }, .{ "try-snowplow.com", {} }, .{ "mafelo.net", {} }, .{ "playstation-cloud.com", {} }, .{ "srht.site", {} }, .{ "apps.lair.io", {} }, .{ "*.stolos.io", {} }, .{ "4.at", {} }, .{ "my.at", {} }, .{ "my.de", {} }, .{ "*.nxa.eu", {} }, .{ "nx.gw", {} }, .{ "spawnbase.app", {} }, .{ "customer.speedpartner.de", {} }, .{ "myspreadshop.at", {} }, .{ "myspreadshop.com.au", {} }, .{ "myspreadshop.be", {} }, .{ "myspreadshop.ca", {} }, .{ "myspreadshop.ch", {} }, .{ "myspreadshop.com", {} }, .{ "myspreadshop.de", {} }, .{ "myspreadshop.dk", {} }, .{ "myspreadshop.es", {} }, .{ "myspreadshop.fi", {} }, .{ "myspreadshop.fr", {} }, .{ "myspreadshop.ie", {} }, .{ "myspreadshop.it", {} }, .{ "myspreadshop.net", {} }, .{ "myspreadshop.nl", {} }, .{ "myspreadshop.no", {} }, .{ "myspreadshop.pl", {} }, .{ "myspreadshop.se", {} }, .{ "myspreadshop.co.uk", {} }, .{ "w-corp-staticblitz.com", {} }, .{ "w-credentialless-staticblitz.com", {} }, .{ "w-staticblitz.com", {} }, .{ "bolt.host", {} }, .{ "stackhero-network.com", {} }, .{ "runs.onstackit.cloud", {} }, .{ "stackit.gg", {} }, .{ "stackit.rocks", {} }, .{ "stackit.run", {} }, .{ "stackit.zone", {} }, .{ "indevs.in", {} }, .{ "musician.io", {} }, .{ "novecore.site", {} }, .{ "api.stdlib.com", {} }, .{ "statichost.page", {} }, .{ "feedback.ac", {} }, .{ "forms.ac", {} }, .{ "assessments.cx", {} }, .{ "calculators.cx", {} }, .{ "funnels.cx", {} }, .{ "paynow.cx", {} }, .{ "quizzes.cx", {} }, .{ "researched.cx", {} }, .{ "tests.cx", {} }, .{ "surveys.so", {} }, .{ "ipfs.storacha.link", {} }, .{ "ipfs.w3s.link", {} }, .{ "storebase.store", {} }, .{ "storj.farm", {} }, .{ "strapiapp.com", {} }, .{ "media.strapiapp.com", {} }, .{ "vps-host.net", {} }, .{ "atl.jelastic.vps-host.net", {} }, .{ "njs.jelastic.vps-host.net", {} }, .{ "ric.jelastic.vps-host.net", {} }, .{ "streak-link.com", {} }, .{ "streaklinks.com", {} }, .{ "streakusercontent.com", {} }, .{ "soc.srcf.net", {} }, .{ "user.srcf.net", {} }, .{ "utwente.io", {} }, .{ "temp-dns.com", {} }, .{ "supabase.co", {} }, .{ "realtime.supabase.co", {} }, .{ "storage.supabase.co", {} }, .{ "supabase.in", {} }, .{ "supabase.net", {} }, .{ "syncloud.it", {} }, .{ "dscloud.biz", {} }, .{ "direct.quickconnect.cn", {} }, .{ "dsmynas.com", {} }, .{ "familyds.com", {} }, .{ "diskstation.me", {} }, .{ "dscloud.me", {} }, .{ "i234.me", {} }, .{ "myds.me", {} }, .{ "synology.me", {} }, .{ "dscloud.mobi", {} }, .{ "dsmynas.net", {} }, .{ "familyds.net", {} }, .{ "dsmynas.org", {} }, .{ "familyds.org", {} }, .{ "direct.quickconnect.to", {} }, .{ "vpnplus.to", {} }, .{ "mytabit.com", {} }, .{ "mytabit.co.il", {} }, .{ "tabitorder.co.il", {} }, .{ "taifun-dns.de", {} }, .{ "erp.dev", {} }, .{ "web.erp.dev", {} }, .{ "ts.net", {} }, .{ "*.c.ts.net", {} }, .{ "gda.pl", {} }, .{ "gdansk.pl", {} }, .{ "gdynia.pl", {} }, .{ "med.pl", {} }, .{ "sopot.pl", {} }, .{ "taveusercontent.com", {} }, .{ "p.tawk.email", {} }, .{ "p.tawkto.email", {} }, .{ "tche.br", {} }, .{ "site.tb-hosting.com", {} }, .{ "directwp.eu", {} }, .{ "ec.cc", {} }, .{ "eu.cc", {} }, .{ "gu.cc", {} }, .{ "uk.cc", {} }, .{ "us.cc", {} }, .{ "edugit.io", {} }, .{ "s3.teckids.org", {} }, .{ "telebit.app", {} }, .{ "telebit.io", {} }, .{ "*.telebit.xyz", {} }, .{ "teleport.sh", {} }, .{ "*.firenet.ch", {} }, .{ "*.svc.firenet.ch", {} }, .{ "reservd.com", {} }, .{ "thingdustdata.com", {} }, .{ "cust.dev.thingdust.io", {} }, .{ "reservd.dev.thingdust.io", {} }, .{ "cust.disrec.thingdust.io", {} }, .{ "reservd.disrec.thingdust.io", {} }, .{ "cust.prod.thingdust.io", {} }, .{ "cust.testing.thingdust.io", {} }, .{ "reservd.testing.thingdust.io", {} }, .{ "tickets.io", {} }, .{ "arvo.network", {} }, .{ "azimuth.network", {} }, .{ "tlon.network", {} }, .{ "torproject.net", {} }, .{ "pages.torproject.net", {} }, .{ "townnews-staging.com", {} }, .{ "12hp.at", {} }, .{ "2ix.at", {} }, .{ "4lima.at", {} }, .{ "lima-city.at", {} }, .{ "12hp.ch", {} }, .{ "2ix.ch", {} }, .{ "4lima.ch", {} }, .{ "lima-city.ch", {} }, .{ "trafficplex.cloud", {} }, .{ "de.cool", {} }, .{ "12hp.de", {} }, .{ "2ix.de", {} }, .{ "4lima.de", {} }, .{ "lima-city.de", {} }, .{ "1337.pictures", {} }, .{ "clan.rip", {} }, .{ "lima-city.rocks", {} }, .{ "webspace.rocks", {} }, .{ "lima.zone", {} }, .{ "*.transurl.be", {} }, .{ "*.transurl.eu", {} }, .{ "site.transip.me", {} }, .{ "*.transurl.nl", {} }, .{ "tunnelmole.net", {} }, .{ "tuxfamily.org", {} }, .{ "typedream.app", {} }, .{ "pro.typeform.com", {} }, .{ "uber.space", {} }, .{ "hk.com", {} }, .{ "inc.hk", {} }, .{ "ltd.hk", {} }, .{ "hk.org", {} }, .{ "it.com", {} }, .{ "umso.co", {} }, .{ "unison-services.cloud", {} }, .{ "virtual-user.de", {} }, .{ "virtualuser.de", {} }, .{ "obj.ag", {} }, .{ "name.pm", {} }, .{ "sch.tf", {} }, .{ "biz.wf", {} }, .{ "sch.wf", {} }, .{ "org.yt", {} }, .{ "rs.ba", {} }, .{ "bielsko.pl", {} }, .{ "urown.cloud", {} }, .{ "dnsupdate.info", {} }, .{ "us.org", {} }, .{ "v.ua", {} }, .{ "val.run", {} }, .{ "web.val.run", {} }, .{ "vercel.app", {} }, .{ "v0.build", {} }, .{ "vercel.dev", {} }, .{ "vusercontent.net", {} }, .{ "vercel.run", {} }, .{ "now.sh", {} }, .{ "2038.io", {} }, .{ "v-info.info", {} }, .{ "vistablog.ir", {} }, .{ "deus-canvas.com", {} }, .{ "voorloper.cloud", {} }, .{ "*.vultrobjects.com", {} }, .{ "wafflecell.com", {} }, .{ "wal.app", {} }, .{ "wasmer.app", {} }, .{ "webflow.io", {} }, .{ "webflowtest.io", {} }, .{ "*.webhare.dev", {} }, .{ "bookonline.app", {} }, .{ "hotelwithflight.com", {} }, .{ "reserve-online.com", {} }, .{ "reserve-online.net", {} }, .{ "cprapid.com", {} }, .{ "pleskns.com", {} }, .{ "wp2.host", {} }, .{ "pdns.page", {} }, .{ "plesk.page", {} }, .{ "cpanel.site", {} }, .{ "wpsquared.site", {} }, .{ "*.wadl.top", {} }, .{ "remotewd.com", {} }, .{ "box.ca", {} }, .{ "pages.wiardweb.com", {} }, .{ "toolforge.org", {} }, .{ "wmcloud.org", {} }, .{ "beta.wmcloud.org", {} }, .{ "wmflabs.org", {} }, .{ "vps.hrsn.au", {} }, .{ "hrsn.dev", {} }, .{ "is-a.dev", {} }, .{ "localcert.net", {} }, .{ "windsurf.app", {} }, .{ "windsurf.build", {} }, .{ "panel.gg", {} }, .{ "daemon.panel.gg", {} }, .{ "base44.app", {} }, .{ "base44-sandbox.com", {} }, .{ "wixsite.com", {} }, .{ "wixstudio.com", {} }, .{ "editorx.io", {} }, .{ "wixstudio.io", {} }, .{ "wix.run", {} }, .{ "messwithdns.com", {} }, .{ "woltlab-demo.com", {} }, .{ "myforum.community", {} }, .{ "community-pro.de", {} }, .{ "diskussionsbereich.de", {} }, .{ "community-pro.net", {} }, .{ "meinforum.net", {} }, .{ "affinitylottery.org.uk", {} }, .{ "raffleentry.org.uk", {} }, .{ "weeklylottery.org.uk", {} }, .{ "wpenginepowered.com", {} }, .{ "js.wpenginepowered.com", {} }, .{ "*.xenonconnect.de", {} }, .{ "half.host", {} }, .{ "xnbay.com", {} }, .{ "u2.xnbay.com", {} }, .{ "u2-local.xnbay.com", {} }, .{ "cistron.nl", {} }, .{ "demon.nl", {} }, .{ "xs4all.space", {} }, .{ "xtooldevice.com", {} }, .{ "yandexcloud.net", {} }, .{ "storage.yandexcloud.net", {} }, .{ "website.yandexcloud.net", {} }, .{ "sourcecraft.site", {} }, .{ "official.academy", {} }, .{ "yolasite.com", {} }, .{ "ynh.fr", {} }, .{ "nohost.me", {} }, .{ "noho.st", {} }, .{ "za.net", {} }, .{ "za.org", {} }, .{ "zap.cloud", {} }, .{ "zeabur.app", {} }, .{ "*.zerops.app", {} }, .{ "bss.design", {} }, .{ "basicserver.io", {} }, .{ "virtualserver.io", {} }, .{ "enterprisecloud.nu", {} }, .{ "zone.id", {} }, .{ "nett.to", {} }, .{ "zabc.net", {} }, }; ================================================ FILE: src/data/public_suffix_list_gen.go ================================================ package main import ( "bufio" "fmt" "net/http" "strings" ) func main() { resp, err := http.Get("https://publicsuffix.org/list/public_suffix_list.dat") if err != nil { panic(err) } defer resp.Body.Close() var domains []string scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if len(line) == 0 || strings.HasPrefix(line, "//") { continue } domains = append(domains, line) } lookup := "const std = @import(\"std\");\n" + "const builtin = @import(\"builtin\");\n\n" + "pub fn lookup(value: []const u8) bool {\n" + " return public_suffix_list.has(value);\n" + "}\n" fmt.Println(lookup) fmt.Println("const public_suffix_list = std.StaticStringMap(void).initComptime(entries);\n") fmt.Println("const entries: []const struct { []const u8, void } =") fmt.Println(" if (builtin.is_test) &.{") fmt.Println(" .{ \"api.gov.uk\", {} },") fmt.Println(" .{ \"gov.uk\", {} },") fmt.Println(" } else &.{") for _, domain := range domains { fmt.Printf(` .{ "%s", {} },`, domain) fmt.Println() } fmt.Println(" };") } ================================================ FILE: src/datetime.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const builtin = @import("builtin"); const posix = std.posix; const Allocator = std.mem.Allocator; pub const Date = struct { year: i16, month: u8, day: u8, pub const Format = enum { iso8601, rfc3339, }; pub fn init(year: i16, month: u8, day: u8) !Date { if (!Date.valid(year, month, day)) { return error.InvalidDate; } return .{ .year = year, .month = month, .day = day, }; } pub fn valid(year: i16, month: u8, day: u8) bool { if (month == 0 or month > 12) { return false; } if (day == 0) { return false; } const month_days = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; const max_days = if (month == 2 and (@rem(year, 400) == 0 or (@rem(year, 100) != 0 and @rem(year, 4) == 0))) 29 else month_days[month - 1]; if (day > max_days) { return false; } return true; } pub fn parse(input: []const u8, fmt: Format) !Date { var parser = Parser.init(input); const date = switch (fmt) { .rfc3339 => try parser.rfc3339Date(), .iso8601 => try parser.iso8601Date(), }; if (parser.unconsumed() != 0) { return error.InvalidDate; } return date; } pub fn order(a: Date, b: Date) std.math.Order { const year_order = std.math.order(a.year, b.year); if (year_order != .eq) return year_order; const month_order = std.math.order(a.month, b.month); if (month_order != .eq) return month_order; return std.math.order(a.day, b.day); } pub fn format(self: Date, writer: *std.Io.Writer) !void { var buf: [11]u8 = undefined; const n = writeDate(&buf, self); try writer.writeAll(buf[0..n]); } pub fn jsonStringify(self: Date, out: anytype) !void { // Our goal here isn't to validate the date. It's to write what we have // in a YYYY-MM-DD format. If the data in Date isn't valid, that's not // our problem and we don't guarantee any reasonable output in such cases. // std.fmt.formatInt is difficult to work with. The padding with signs // doesn't work and it'll always put a + sign given a signed integer with padding // So, for year, we always feed it an unsigned number (which avoids both issues) // and prepend the - if we need it.s var buf: [13]u8 = undefined; const n = writeDate(buf[1..12], self); buf[0] = '"'; buf[n + 1] = '"'; try out.print("{s}", .{buf[0 .. n + 2]}); } pub fn jsonParse(allocator: Allocator, source: anytype, options: anytype) !Date { _ = options; switch (try source.nextAlloc(allocator, .alloc_if_needed)) { inline .string, .allocated_string => |str| return Date.parse(str, .rfc3339) catch return error.InvalidCharacter, else => return error.UnexpectedToken, } } }; pub const Time = struct { hour: u8, min: u8, sec: u8, micros: u32, pub const Format = enum { rfc3339, }; pub fn init(hour: u8, min: u8, sec: u8, micros: u32) !Time { if (!Time.valid(hour, min, sec, micros)) { return error.InvalidTime; } return .{ .hour = hour, .min = min, .sec = sec, .micros = micros, }; } pub fn valid(hour: u8, min: u8, sec: u8, micros: u32) bool { if (hour > 23) { return false; } if (min > 59) { return false; } if (sec > 59) { return false; } if (micros > 999999) { return false; } return true; } pub fn parse(input: []const u8, fmt: Format) !Time { var parser = Parser.init(input); const time = switch (fmt) { .rfc3339 => try parser.time(true), }; if (parser.unconsumed() != 0) { return error.InvalidTime; } return time; } pub fn order(a: Time, b: Time) std.math.Order { const hour_order = std.math.order(a.hour, b.hour); if (hour_order != .eq) return hour_order; const min_order = std.math.order(a.min, b.min); if (min_order != .eq) return min_order; const sec_order = std.math.order(a.sec, b.sec); if (sec_order != .eq) return sec_order; return std.math.order(a.micros, b.micros); } pub fn format(self: Time, writer: *std.Io.Writer) !void { var buf: [15]u8 = undefined; const n = writeTime(&buf, self); try writer.writeAll(buf[0..n]); } pub fn jsonStringify(self: Time, out: anytype) !void { // Our goal here isn't to validate the time. It's to write what we have // in a hh:mm:ss.sss format. If the data in Time isn't valid, that's not // our problem and we don't guarantee any reasonable output in such cases. var buf: [17]u8 = undefined; const n = writeTime(buf[1..16], self); buf[0] = '"'; buf[n + 1] = '"'; try out.print("{s}", .{buf[0 .. n + 2]}); } pub fn jsonParse(allocator: Allocator, source: anytype, options: anytype) !Time { _ = options; switch (try source.nextAlloc(allocator, .alloc_if_needed)) { inline .string, .allocated_string => |str| return Time.parse(str, .rfc3339) catch return error.InvalidCharacter, else => return error.UnexpectedToken, } } }; pub const DateTime = struct { micros: i64, const MICROSECONDS_IN_A_DAY = 86_400_000_000; const MICROSECONDS_IN_AN_HOUR = 3_600_000_000; const MICROSECONDS_IN_A_MIN = 60_000_000; const MICROSECONDS_IN_A_SEC = 1_000_000; pub const Format = enum { rfc822, rfc3339, }; pub const TimestampPrecision = enum { seconds, milliseconds, microseconds, }; pub const TimeUnit = enum { days, hours, minutes, seconds, milliseconds, microseconds, }; // https://blog.reverberate.org/2020/05/12/optimizing-date-algorithms.html pub fn initUTC(year: i16, month: u8, day: u8, hour: u8, min: u8, sec: u8, micros: u32) !DateTime { if (Date.valid(year, month, day) == false) { return error.InvalidDate; } if (Time.valid(hour, min, sec, micros) == false) { return error.InvalidTime; } const year_base = 4800; const month_adj = @as(i32, @intCast(month)) - 3; // March-based month const carry: u8 = if (month_adj < 0) 1 else 0; const adjust: u8 = if (carry == 1) 12 else 0; const year_adj: i64 = year + year_base - carry; const month_days = @divTrunc(((month_adj + adjust) * 62719 + 769), 2048); const leap_days = @divTrunc(year_adj, 4) - @divTrunc(year_adj, 100) + @divTrunc(year_adj, 400); const date_micros: i64 = (year_adj * 365 + leap_days + month_days + (day - 1) - 2472632) * MICROSECONDS_IN_A_DAY; const time_micros = (@as(i64, @intCast(hour)) * MICROSECONDS_IN_AN_HOUR) + (@as(i64, @intCast(min)) * MICROSECONDS_IN_A_MIN) + (@as(i64, @intCast(sec)) * MICROSECONDS_IN_A_SEC) + micros; return fromUnix(date_micros + time_micros, .microseconds); } pub fn fromUnix(value: i64, precision: TimestampPrecision) !DateTime { switch (precision) { .seconds => { if (value < -210863520000 or value > 253402300799) { return error.OutsideJulianPeriod; } return .{ .micros = value * 1_000_000 }; }, .milliseconds => { if (value < -210863520000000 or value > 253402300799999) { return error.OutsideJulianPeriod; } return .{ .micros = value * 1_000 }; }, .microseconds => { if (value < -210863520000000000 or value > 253402300799999999) { return error.OutsideJulianPeriod; } return .{ .micros = value }; }, } } pub fn now() DateTime { return .{ .micros = std.time.microTimestamp(), }; } pub fn parse(input: []const u8, fmt: Format) !DateTime { switch (fmt) { .rfc822 => return parseRFC822(input), .rfc3339 => return parseRFC3339(input), } } pub fn parseRFC822(input: []const u8) !DateTime { if (input.len < 10) { return error.InvalidDateTime; } var parser = Parser.init(input); if (input[3] == ',' and input[4] == ' ') { _ = std.meta.stringToEnum(enum { Mon, Tue, Wed, Thu, Fri, Sat, Sun }, input[0..3]) orelse return error.InvalidDate; // skip over the "DoW, " parser.pos = 5; } const day = parser.paddedInt(u8, 2) orelse return error.InvalidDate; if (parser.consumeIf(' ') == false) { return error.InvalidDate; } const month = std.meta.stringToEnum(enum { Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec }, parser.consumeN(3) orelse return error.InvalidDate) orelse return error.InvalidDate; if (parser.consumeIf(' ') == false) { return error.InvalidDate; } const year = parser.paddedInt(i16, 4) orelse blk: { const short_year = parser.paddedInt(u8, 2) orelse return error.InvalidDate; break :blk if (short_year > 68) 1900 + @as(i16, short_year) else 2000 + @as(i16, short_year); }; if (parser.consumeIf(' ') == false) { return error.InvalidDateTime; } const tm = try parser.time(false); if (parser.consumeIf(' ') == false) { return error.InvalidTime; } _ = std.meta.stringToEnum(enum { UT, GMT, Z }, parser.rest()) orelse return error.UnsupportedTimeZone; return initUTC(year, @intFromEnum(month) + 1, day, tm.hour, tm.min, tm.sec, tm.micros); } pub fn parseRFC3339(input: []const u8) !DateTime { var parser = Parser.init(input); const dt = try parser.rfc3339Date(); const year = dt.year; if (year < -4712 or year > 9999) { return error.OutsideJulianPeriod; } // Per the spec, it can be argued thatt 't' and even ' ' should be allowed, // but certainly not encouraged. if (parser.consumeIf('T') == false) { return error.InvalidDateTime; } const tm = try parser.time(true); switch (parser.unconsumed()) { 0 => return error.InvalidDateTime, 1 => if (parser.consumeIf('Z') == false) { return error.InvalidDateTime; }, 6 => { const suffix = parser.rest(); if (suffix[0] != '+' and suffix[0] != '-') { return error.InvalidDateTime; } if (std.mem.eql(u8, suffix[1..], "00:00") == false) { return error.NonUTCNotSupported; } }, else => return error.InvalidDateTime, } return initUTC(dt.year, dt.month, dt.day, tm.hour, tm.min, tm.sec, tm.micros); } pub fn add(dt: DateTime, value: i64, unit: TimeUnit) !DateTime { const micros = dt.micros; switch (unit) { .days => return fromUnix(micros + value * MICROSECONDS_IN_A_DAY, .microseconds), .hours => return fromUnix(micros + value * MICROSECONDS_IN_AN_HOUR, .microseconds), .minutes => return fromUnix(micros + value * MICROSECONDS_IN_A_MIN, .microseconds), .seconds => return fromUnix(micros + value * MICROSECONDS_IN_A_SEC, .microseconds), .milliseconds => return fromUnix(micros + value * 1_000, .microseconds), .microseconds => return fromUnix(micros + value, .microseconds), } } pub fn sub(a: DateTime, b: DateTime, precision: TimestampPrecision) i64 { return a.unix(precision) - b.unix(precision); } // https://git.musl-libc.org/cgit/musl/tree/src/time/__secs_to_tm.c?h=v0.9.15 pub fn date(dt: DateTime) Date { // 2000-03-01 (mod 400 year, immediately after feb29 const leap_epoch = 946684800 + 86400 * (31 + 29); const days_per_400y = 365 * 400 + 97; const days_per_100y = 365 * 100 + 24; const days_per_4y = 365 * 4 + 1; // march-based const month_days = [_]u8{ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 31, 29 }; const secs = @divTrunc(dt.micros, 1_000_000) - leap_epoch; var days = @divTrunc(secs, 86400); if (@rem(secs, 86400) < 0) { days -= 1; } var qc_cycles = @divTrunc(days, days_per_400y); var rem_days = @rem(days, days_per_400y); if (rem_days < 0) { rem_days += days_per_400y; qc_cycles -= 1; } var c_cycles = @divTrunc(rem_days, days_per_100y); if (c_cycles == 4) { c_cycles -= 1; } rem_days -= c_cycles * days_per_100y; var q_cycles = @divTrunc(rem_days, days_per_4y); if (q_cycles == 25) { q_cycles -= 1; } rem_days -= q_cycles * days_per_4y; var rem_years = @divTrunc(rem_days, 365); if (rem_years == 4) { rem_years -= 1; } rem_days -= rem_years * 365; var year = rem_years + 4 * q_cycles + 100 * c_cycles + 400 * qc_cycles + 2000; var month: u8 = 0; while (month_days[month] <= rem_days) : (month += 1) { rem_days -= month_days[month]; } month += 2; if (month >= 12) { year += 1; month -= 12; } return .{ .year = @intCast(year), .month = month + 1, .day = @intCast(rem_days + 1), }; } pub fn time(dt: DateTime) Time { const micros = @mod(dt.micros, MICROSECONDS_IN_A_DAY); return .{ .hour = @intCast(@divTrunc(micros, MICROSECONDS_IN_AN_HOUR)), .min = @intCast(@divTrunc(@rem(micros, MICROSECONDS_IN_AN_HOUR), MICROSECONDS_IN_A_MIN)), .sec = @intCast(@divTrunc(@rem(micros, MICROSECONDS_IN_A_MIN), MICROSECONDS_IN_A_SEC)), .micros = @intCast(@rem(micros, MICROSECONDS_IN_A_SEC)), }; } pub fn unix(self: DateTime, precision: TimestampPrecision) i64 { const micros = self.micros; return switch (precision) { .seconds => @divTrunc(micros, 1_000_000), .milliseconds => @divTrunc(micros, 1_000), .microseconds => micros, }; } pub fn order(a: DateTime, b: DateTime) std.math.Order { return std.math.order(a.micros, b.micros); } pub fn format(self: DateTime, writer: *std.Io.Writer) !void { var buf: [28]u8 = undefined; const n = self.bufWrite(&buf); try writer.writeAll(buf[0..n]); } pub fn jsonStringify(self: DateTime, out: anytype) !void { var buf: [30]u8 = undefined; buf[0] = '"'; const n = self.bufWrite(buf[1..]); buf[n + 1] = '"'; try out.print("{s}", .{buf[0 .. n + 2]}); } pub fn jsonParse(allocator: Allocator, source: anytype, options: anytype) !DateTime { _ = options; switch (try source.nextAlloc(allocator, .alloc_if_needed)) { inline .string, .allocated_string => |str| return parseRFC3339(str) catch return error.InvalidCharacter, else => return error.UnexpectedToken, } } fn bufWrite(self: DateTime, buf: []u8) usize { const date_n = writeDate(buf, self.date()); buf[date_n] = 'T'; const time_start = date_n + 1; const time_n = writeTime(buf[time_start..], self.time()); const time_stop = time_start + time_n; buf[time_stop] = 'Z'; return time_stop + 1; } }; // true if we should use clock_gettime() const is_posix = switch (builtin.os.tag) { .windows, .uefi, .wasi => false, else => true, }; pub const TimestampMode = enum { clock, monotonic, }; pub fn timestamp(comptime mode: TimestampMode) u64 { if (comptime is_posix == false or mode == .clock) { return @intCast(std.time.timestamp()); } const ts = timespec(); return @intCast(ts.sec); } pub fn milliTimestamp(comptime mode: TimestampMode) u64 { if (comptime is_posix == false or mode == .clock) { return @intCast(std.time.milliTimestamp()); } const ts = timespec(); return @as(u64, @intCast(ts.sec)) * 1000 + @as(u64, @intCast(@divTrunc(ts.nsec, 1_000_000))); } pub fn timespec() posix.timespec { if (comptime is_posix == false) { @compileError("`timespec` should not be called when `is_posix` is false"); } const clock_id = switch (@import("builtin").os.tag) { .freebsd, .dragonfly => posix.CLOCK.MONOTONIC_FAST, .macos, .ios, .tvos, .watchos, .visionos => posix.CLOCK.UPTIME_RAW, // continues counting while suspended .linux => posix.CLOCK.BOOTTIME, // continues counting while suspended else => posix.CLOCK.MONOTONIC, }; // unreac return posix.clock_gettime(clock_id) catch unreachable; } fn writeDate(into: []u8, date: Date) u8 { var buf: []u8 = undefined; // cast year to a u16 so it doesn't insert a sign // we don't want the + sign, ever // and we don't even want it to insert the - sign, because it screws up // the padding (we need to do it ourselfs) const year = date.year; if (year < 0) { _ = std.fmt.printInt(into[1..], @as(u16, @intCast(year * -1)), 10, .lower, .{ .width = 4, .fill = '0' }); into[0] = '-'; buf = into[5..]; } else { _ = std.fmt.printInt(into, @as(u16, @intCast(year)), 10, .lower, .{ .width = 4, .fill = '0' }); buf = into[4..]; } buf[0] = '-'; buf[1..3].* = paddingTwoDigits(date.month); buf[3] = '-'; buf[4..6].* = paddingTwoDigits(date.day); // return the length of the string. 10 for positive year, 11 for negative return if (year < 0) 11 else 10; } fn writeTime(into: []u8, time: Time) u8 { into[0..2].* = paddingTwoDigits(time.hour); into[2] = ':'; into[3..5].* = paddingTwoDigits(time.min); into[5] = ':'; into[6..8].* = paddingTwoDigits(time.sec); const micros = time.micros; if (micros == 0) { return 8; } if (@rem(micros, 1000) == 0) { into[8] = '.'; _ = std.fmt.printInt(into[9..12], micros / 1000, 10, .lower, .{ .width = 3, .fill = '0' }); return 12; } into[8] = '.'; _ = std.fmt.printInt(into[9..15], micros, 10, .lower, .{ .width = 6, .fill = '0' }); return 15; } fn paddingTwoDigits(value: usize) [2]u8 { lp.assert(value < 61, "datetime.paddingTwoDigits", .{ .value = value }); const digits = "0001020304050607080910111213141516171819" ++ "2021222324252627282930313233343536373839" ++ "4041424344454647484950515253545556575859" ++ "60"; return digits[value * 2 ..][0..2].*; } const Parser = struct { input: []const u8, pos: usize, fn init(input: []const u8) Parser { return .{ .pos = 0, .input = input, }; } fn unconsumed(self: *const Parser) usize { return self.input.len - self.pos; } fn rest(self: *const Parser) []const u8 { return self.input[self.pos..]; } // unsafe, assumes caller has checked remaining first fn peek(self: *const Parser) u8 { return self.input[self.pos]; } // unsafe, assumes caller has checked remaining first fn consumeIf(self: *Parser, c: u8) bool { const pos = self.pos; if (self.input[pos] != c) { return false; } self.pos = pos + 1; return true; } fn consumeN(self: *Parser, n: usize) ?[]const u8 { const pos = self.pos; const end = pos + n; if (end > self.input.len) { return null; } defer self.pos = end; return self.input[pos..end]; } fn nanoseconds(self: *Parser) ?usize { const start = self.pos; const input = self.input[start..]; var len = input.len; if (len == 0) { return null; } var value: usize = 0; for (input, 0..) |b, i| { const n = b -% '0'; // wrapping subtraction if (n > 9) { len = i; break; } value = value * 10 + n; } if (len > 9) { return null; } self.pos = start + len; return value * std.math.pow(usize, 10, 9 - len); } fn paddedInt(self: *Parser, comptime T: type, size: u8) ?T { const pos = self.pos; const end = pos + size; const input = self.input; if (end > input.len) { return null; } var value: T = 0; for (input[pos..end]) |b| { const n = b -% '0'; // wrapping subtraction if (n > 9) return null; value = value * 10 + n; } self.pos = end; return value; } fn time(self: *Parser, allow_nano: bool) !Time { const len = self.unconsumed(); if (len < 5) { return error.InvalidTime; } const hour = self.paddedInt(u8, 2) orelse return error.InvalidTime; if (self.consumeIf(':') == false) { return error.InvalidTime; } const min = self.paddedInt(u8, 2) orelse return error.InvalidTime; if (len == 5 or self.consumeIf(':') == false) { return Time.init(hour, min, 0, 0); } const sec = self.paddedInt(u8, 2) orelse return error.InvalidTime; if (allow_nano == false or len == 8 or self.consumeIf('.') == false) { return Time.init(hour, min, sec, 0); } const nanos = self.nanoseconds() orelse return error.InvalidTime; return Time.init(hour, min, sec, @intCast(nanos / 1000)); } fn iso8601Date(self: *Parser) !Date { const len = self.unconsumed(); if (len < 8) { return error.InvalidDate; } const negative = self.consumeIf('-'); const year = self.paddedInt(i16, 4) orelse return error.InvalidDate; var with_dashes = false; if (self.consumeIf('-')) { if (len < 10) { return error.InvalidDate; } with_dashes = true; } const month = self.paddedInt(u8, 2) orelse return error.InvalidDate; if (self.consumeIf('-') == !with_dashes) { return error.InvalidDate; } const day = self.paddedInt(u8, 2) orelse return error.InvalidDate; return Date.init(if (negative) -year else year, month, day); } fn rfc3339Date(self: *Parser) !Date { const len = self.unconsumed(); if (len < 10) { return error.InvalidDate; } const negative = self.consumeIf('-'); const year = self.paddedInt(i16, 4) orelse return error.InvalidDate; if (self.consumeIf('-') == false) { return error.InvalidDate; } const month = self.paddedInt(u8, 2) orelse return error.InvalidDate; if (self.consumeIf('-') == false) { return error.InvalidDate; } const day = self.paddedInt(u8, 2) orelse return error.InvalidDate; return Date.init(if (negative) -year else year, month, day); } }; const testing = @import("testing.zig"); test "Date: json" { { // date, positive year const date = Date{ .year = 2023, .month = 9, .day = 22 }; const out = try std.json.Stringify.valueAlloc(testing.allocator, date, .{}); defer testing.allocator.free(out); try testing.expectString("\"2023-09-22\"", out); } { // date, negative year const date = Date{ .year = -4, .month = 12, .day = 3 }; const out = try std.json.Stringify.valueAlloc(testing.allocator, date, .{}); defer testing.allocator.free(out); try testing.expectString("\"-0004-12-03\"", out); } { // parse const ts = try std.json.parseFromSlice(TestStruct, testing.allocator, "{\"date\":\"2023-09-22\"}", .{}); defer ts.deinit(); try testing.expectEqual(Date{ .year = 2023, .month = 9, .day = 22 }, ts.value.date.?); } } test "Date: format" { { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Date{ .year = 2023, .month = 5, .day = 22 }}); try testing.expectString("2023-05-22", out); } { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Date{ .year = -102, .month = 12, .day = 9 }}); try testing.expectString("-0102-12-09", out); } } test "Date: parse ISO8601" { { //valid YYYY-MM-DD try testing.expectEqual(Date{ .year = 2023, .month = 5, .day = 22 }, try Date.parse("2023-05-22", .iso8601)); try testing.expectEqual(Date{ .year = -2023, .month = 2, .day = 3 }, try Date.parse("-2023-02-03", .iso8601)); try testing.expectEqual(Date{ .year = 1, .month = 2, .day = 3 }, try Date.parse("0001-02-03", .iso8601)); try testing.expectEqual(Date{ .year = -1, .month = 2, .day = 3 }, try Date.parse("-0001-02-03", .iso8601)); } { //valid YYYYMMDD try testing.expectEqual(Date{ .year = 2023, .month = 5, .day = 22 }, try Date.parse("20230522", .iso8601)); try testing.expectEqual(Date{ .year = -2023, .month = 2, .day = 3 }, try Date.parse("-20230203", .iso8601)); try testing.expectEqual(Date{ .year = 1, .month = 2, .day = 3 }, try Date.parse("00010203", .iso8601)); try testing.expectEqual(Date{ .year = -1, .month = 2, .day = 3 }, try Date.parse("-00010203", .iso8601)); } } test "Date: parse RFC339" { { //valid YYYY-MM-DD try testing.expectEqual(Date{ .year = 2023, .month = 5, .day = 22 }, try Date.parse("2023-05-22", .rfc3339)); try testing.expectEqual(Date{ .year = -2023, .month = 2, .day = 3 }, try Date.parse("-2023-02-03", .rfc3339)); try testing.expectEqual(Date{ .year = 1, .month = 2, .day = 3 }, try Date.parse("0001-02-03", .rfc3339)); try testing.expectEqual(Date{ .year = -1, .month = 2, .day = 3 }, try Date.parse("-0001-02-03", .rfc3339)); } { //valid YYYYMMDD try testing.expectError(error.InvalidDate, Date.parse("20230522", .rfc3339)); try testing.expectError(error.InvalidDate, Date.parse("-20230203", .rfc3339)); try testing.expectError(error.InvalidDate, Date.parse("00010203", .rfc3339)); try testing.expectError(error.InvalidDate, Date.parse("-00010203", .rfc3339)); } } test "Date: parse invalid common" { for (&[_]Date.Format{ .rfc3339, .iso8601 }) |format| { { // invalid format try testing.expectError(error.InvalidDate, Date.parse("", format)); try testing.expectError(error.InvalidDate, Date.parse("2023/01-02", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-01/02", format)); try testing.expectError(error.InvalidDate, Date.parse("0001-01-01 ", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-1-02", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-01-2", format)); try testing.expectError(error.InvalidDate, Date.parse("9-01-2", format)); try testing.expectError(error.InvalidDate, Date.parse("99-01-2", format)); try testing.expectError(error.InvalidDate, Date.parse("999-01-2", format)); try testing.expectError(error.InvalidDate, Date.parse("-999-01-2", format)); try testing.expectError(error.InvalidDate, Date.parse("-1-01-2", format)); } { // invalid month try testing.expectError(error.InvalidDate, Date.parse("2023-00-22", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-0A-22", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-13-22", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-99-22", format)); try testing.expectError(error.InvalidDate, Date.parse("-2023-00-22", format)); try testing.expectError(error.InvalidDate, Date.parse("-2023-13-22", format)); try testing.expectError(error.InvalidDate, Date.parse("-2023-99-22", format)); } { // invalid day try testing.expectError(error.InvalidDate, Date.parse("2023-01-00", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-01-32", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-02-29", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-03-32", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-04-31", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-05-32", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-06-31", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-07-32", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-08-32", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-09-31", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-10-32", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-11-31", format)); try testing.expectError(error.InvalidDate, Date.parse("2023-12-32", format)); } { // valid (max day) try testing.expectEqual(Date{ .year = 2023, .month = 1, .day = 31 }, try Date.parse("2023-01-31", format)); try testing.expectEqual(Date{ .year = 2023, .month = 2, .day = 28 }, try Date.parse("2023-02-28", format)); try testing.expectEqual(Date{ .year = 2023, .month = 3, .day = 31 }, try Date.parse("2023-03-31", format)); try testing.expectEqual(Date{ .year = 2023, .month = 4, .day = 30 }, try Date.parse("2023-04-30", format)); try testing.expectEqual(Date{ .year = 2023, .month = 5, .day = 31 }, try Date.parse("2023-05-31", format)); try testing.expectEqual(Date{ .year = 2023, .month = 6, .day = 30 }, try Date.parse("2023-06-30", format)); try testing.expectEqual(Date{ .year = 2023, .month = 7, .day = 31 }, try Date.parse("2023-07-31", format)); try testing.expectEqual(Date{ .year = 2023, .month = 8, .day = 31 }, try Date.parse("2023-08-31", format)); try testing.expectEqual(Date{ .year = 2023, .month = 9, .day = 30 }, try Date.parse("2023-09-30", format)); try testing.expectEqual(Date{ .year = 2023, .month = 10, .day = 31 }, try Date.parse("2023-10-31", format)); try testing.expectEqual(Date{ .year = 2023, .month = 11, .day = 30 }, try Date.parse("2023-11-30", format)); try testing.expectEqual(Date{ .year = 2023, .month = 12, .day = 31 }, try Date.parse("2023-12-31", format)); } { // leap years try testing.expectEqual(Date{ .year = 2000, .month = 2, .day = 29 }, try Date.parse("2000-02-29", format)); try testing.expectEqual(Date{ .year = 2400, .month = 2, .day = 29 }, try Date.parse("2400-02-29", format)); try testing.expectEqual(Date{ .year = 2012, .month = 2, .day = 29 }, try Date.parse("2012-02-29", format)); try testing.expectEqual(Date{ .year = 2024, .month = 2, .day = 29 }, try Date.parse("2024-02-29", format)); try testing.expectError(error.InvalidDate, Date.parse("2000-02-30", format)); try testing.expectError(error.InvalidDate, Date.parse("2400-02-30", format)); try testing.expectError(error.InvalidDate, Date.parse("2012-02-30", format)); try testing.expectError(error.InvalidDate, Date.parse("2024-02-30", format)); try testing.expectError(error.InvalidDate, Date.parse("2100-02-29", format)); try testing.expectError(error.InvalidDate, Date.parse("2200-02-29", format)); } } } test "Date: order" { { const a = Date{ .year = 2023, .month = 5, .day = 22 }; const b = Date{ .year = 2023, .month = 5, .day = 22 }; try testing.expectEqual(std.math.Order.eq, a.order(b)); } { const a = Date{ .year = 2023, .month = 5, .day = 22 }; const b = Date{ .year = 2022, .month = 5, .day = 22 }; try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = Date{ .year = 2022, .month = 6, .day = 22 }; const b = Date{ .year = 2022, .month = 5, .day = 22 }; try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = Date{ .year = 2023, .month = 5, .day = 23 }; const b = Date{ .year = 2022, .month = 5, .day = 22 }; try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } } test "Time: json" { { // time no fraction const time = Time{ .hour = 23, .min = 59, .sec = 2, .micros = 0 }; const out = try std.json.Stringify.valueAlloc(testing.allocator, time, .{}); defer testing.allocator.free(out); try testing.expectString("\"23:59:02\"", out); } { // time, milliseconds only const time = Time{ .hour = 7, .min = 9, .sec = 32, .micros = 202000 }; const out = try std.json.Stringify.valueAlloc(testing.allocator, time, .{}); defer testing.allocator.free(out); try testing.expectString("\"07:09:32.202\"", out); } { // time, micros const time = Time{ .hour = 1, .min = 2, .sec = 3, .micros = 123456 }; const out = try std.json.Stringify.valueAlloc(testing.allocator, time, .{}); defer testing.allocator.free(out); try testing.expectString("\"01:02:03.123456\"", out); } { // parse const ts = try std.json.parseFromSlice(TestStruct, testing.allocator, "{\"time\":\"01:02:03.123456\"}", .{}); defer ts.deinit(); try testing.expectEqual(Time{ .hour = 1, .min = 2, .sec = 3, .micros = 123456 }, ts.value.time.?); } } test "Time: format" { { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Time{ .hour = 23, .min = 59, .sec = 59, .micros = 0 }}); try testing.expectString("23:59:59", out); } { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Time{ .hour = 8, .min = 9, .sec = 10, .micros = 12 }}); try testing.expectString("08:09:10.000012", out); } { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Time{ .hour = 8, .min = 9, .sec = 10, .micros = 123 }}); try testing.expectString("08:09:10.000123", out); } { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Time{ .hour = 8, .min = 9, .sec = 10, .micros = 1234 }}); try testing.expectString("08:09:10.001234", out); } { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Time{ .hour = 8, .min = 9, .sec = 10, .micros = 12345 }}); try testing.expectString("08:09:10.012345", out); } { var buf: [20]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{Time{ .hour = 8, .min = 9, .sec = 10, .micros = 123456 }}); try testing.expectString("08:09:10.123456", out); } } test "Time: parse" { { //valid try testing.expectEqual(Time{ .hour = 9, .min = 8, .sec = 0, .micros = 0 }, try Time.parse("09:08", .rfc3339)); try testing.expectEqual(Time{ .hour = 9, .min = 8, .sec = 5, .micros = 123000 }, try Time.parse("09:08:05.123", .rfc3339)); try testing.expectEqual(Time{ .hour = 23, .min = 59, .sec = 59, .micros = 0 }, try Time.parse("23:59:59", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 0 }, try Time.parse("00:00:00", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 0 }, try Time.parse("00:00:00.0", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 1 }, try Time.parse("00:00:00.000001", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 12 }, try Time.parse("00:00:00.000012", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 123 }, try Time.parse("00:00:00.000123", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 1234 }, try Time.parse("00:00:00.001234", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 12345 }, try Time.parse("00:00:00.012345", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 123456 }, try Time.parse("00:00:00.123456", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 123456 }, try Time.parse("00:00:00.1234567", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 123456 }, try Time.parse("00:00:00.12345678", .rfc3339)); try testing.expectEqual(Time{ .hour = 0, .min = 0, .sec = 0, .micros = 123456 }, try Time.parse("00:00:00.123456789", .rfc3339)); } { try testing.expectError(error.InvalidTime, Time.parse("", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("01:00:", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("1:00:00", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("10:1:00", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("10:11:4", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("10:20:30.", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("10:20:30.a", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("10:20:30.1234567899", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("10:20:30.123Z", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("24:00:00", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("00:60:00", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("00:00:60", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("0a:00:00", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("00:0a:00", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("00:00:0a", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("00/00:00", .rfc3339)); try testing.expectError(error.InvalidTime, Time.parse("00:00 00", .rfc3339)); } } test "Time: order" { { const a = Time{ .hour = 19, .min = 17, .sec = 22, .micros = 101002 }; const b = Time{ .hour = 19, .min = 17, .sec = 22, .micros = 101002 }; try testing.expectEqual(std.math.Order.eq, a.order(b)); } { const a = Time{ .hour = 20, .min = 17, .sec = 22, .micros = 101002 }; const b = Time{ .hour = 19, .min = 17, .sec = 22, .micros = 101002 }; try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = Time{ .hour = 19, .min = 18, .sec = 22, .micros = 101002 }; const b = Time{ .hour = 19, .min = 17, .sec = 22, .micros = 101002 }; try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = Time{ .hour = 19, .min = 17, .sec = 23, .micros = 101002 }; const b = Time{ .hour = 19, .min = 17, .sec = 22, .micros = 101002 }; try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = Time{ .hour = 19, .min = 17, .sec = 22, .micros = 101003 }; const b = Time{ .hour = 19, .min = 17, .sec = 22, .micros = 101002 }; try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } } test "DateTime: initUTC" { // GO // for i := 0; i < 100; i++ { // us := rand.Int63n(31536000000000000) // if i%2 == 1 { // us = -us // } // date := time.UnixMicro(us).UTC() // fmt.Printf("\ttry testing.expectEqual(%d, (try DateTime.initUTC(%d, %d, %d, %d, %d, %d, %d)).micros);\n", us, date.Year(), date.Month(), date.Day(), date.Hour(), date.Minute(), date.Second(), date.Nanosecond()/1000) // } try testing.expectEqual(31185488490276150, (try DateTime.initUTC(2958, 3, 25, 3, 41, 30, 276150)).micros); try testing.expectEqual(-17564653328342207, (try DateTime.initUTC(1413, 5, 26, 9, 37, 51, 657793)).micros); try testing.expectEqual(11204762425459393, (try DateTime.initUTC(2325, 1, 24, 18, 0, 25, 459393)).micros); try testing.expectEqual(-11416605162739875, (try DateTime.initUTC(1608, 3, 22, 8, 47, 17, 260125)).micros); try testing.expectEqual(4075732367920414, (try DateTime.initUTC(2099, 2, 25, 19, 52, 47, 920414)).micros); try testing.expectEqual(-18408335598163579, (try DateTime.initUTC(1386, 8, 30, 13, 26, 41, 836421)).micros); try testing.expectEqual(17086490946271926, (try DateTime.initUTC(2511, 6, 14, 7, 29, 6, 271926)).micros); try testing.expectEqual(-235277150936616, (try DateTime.initUTC(1962, 7, 18, 21, 14, 9, 63384)).micros); try testing.expectEqual(11104788804726682, (try DateTime.initUTC(2321, 11, 24, 15, 33, 24, 726682)).micros); try testing.expectEqual(-4568937205156452, (try DateTime.initUTC(1825, 3, 20, 18, 46, 34, 843548)).micros); try testing.expectEqual(24765673968274275, (try DateTime.initUTC(2754, 10, 17, 17, 52, 48, 274275)).micros); try testing.expectEqual(-7121990846251510, (try DateTime.initUTC(1744, 4, 24, 13, 12, 33, 748490)).micros); try testing.expectEqual(17226397205968456, (try DateTime.initUTC(2515, 11, 19, 14, 20, 5, 968456)).micros); try testing.expectEqual(-6754262392339050, (try DateTime.initUTC(1755, 12, 19, 16, 0, 7, 660950)).micros); try testing.expectEqual(16357572620714009, (try DateTime.initUTC(2488, 5, 7, 18, 10, 20, 714009)).micros); try testing.expectEqual(-25688820176639049, (try DateTime.initUTC(1155, 12, 15, 16, 37, 3, 360951)).micros); try testing.expectEqual(20334458172336139, (try DateTime.initUTC(2614, 5, 17, 12, 36, 12, 336139)).micros); try testing.expectEqual(-30602962159178117, (try DateTime.initUTC(1000, 3, 26, 1, 10, 40, 821883)).micros); try testing.expectEqual(10851036879825648, (try DateTime.initUTC(2313, 11, 9, 16, 54, 39, 825648)).micros); try testing.expectEqual(-21853769826060317, (try DateTime.initUTC(1277, 6, 24, 20, 22, 53, 939683)).micros); try testing.expectEqual(23747326217087461, (try DateTime.initUTC(2722, 7, 11, 7, 30, 17, 87461)).micros); try testing.expectEqual(-6579703114708064, (try DateTime.initUTC(1761, 7, 1, 0, 41, 25, 291936)).micros); try testing.expectEqual(14734931422924073, (try DateTime.initUTC(2436, 12, 6, 4, 30, 22, 924073)).micros); try testing.expectEqual(-14370161672281011, (try DateTime.initUTC(1514, 8, 18, 16, 25, 27, 718989)).micros); try testing.expectEqual(21611484560584058, (try DateTime.initUTC(2654, 11, 3, 22, 9, 20, 584058)).micros); try testing.expectEqual(-15774514890527755, (try DateTime.initUTC(1470, 2, 15, 14, 18, 29, 472245)).micros); try testing.expectEqual(12457884381373706, (try DateTime.initUTC(2364, 10, 10, 11, 26, 21, 373706)).micros); try testing.expectEqual(-9291409512875127, (try DateTime.initUTC(1675, 7, 26, 12, 54, 47, 124873)).micros); try testing.expectEqual(18766703512694310, (try DateTime.initUTC(2564, 9, 10, 5, 11, 52, 694310)).micros); try testing.expectEqual(-10898338457124469, (try DateTime.initUTC(1624, 8, 23, 19, 45, 42, 875531)).micros); try testing.expectEqual(27404278841361952, (try DateTime.initUTC(2838, 5, 29, 3, 40, 41, 361952)).micros); try testing.expectEqual(-11493696741549109, (try DateTime.initUTC(1605, 10, 12, 2, 27, 38, 450891)).micros); try testing.expectEqual(25167839321247044, (try DateTime.initUTC(2767, 7, 16, 10, 28, 41, 247044)).micros); try testing.expectEqual(-8645720427930599, (try DateTime.initUTC(1696, 1, 10, 18, 59, 32, 69401)).micros); try testing.expectEqual(7021225980669527, (try DateTime.initUTC(2192, 6, 29, 4, 33, 0, 669527)).micros); try testing.expectEqual(-22567421500525473, (try DateTime.initUTC(1254, 11, 12, 23, 48, 19, 474527)).micros); try testing.expectEqual(3592419409525180, (try DateTime.initUTC(2083, 11, 2, 22, 16, 49, 525180)).micros); try testing.expectEqual(-24897829995733878, (try DateTime.initUTC(1181, 1, 7, 16, 6, 44, 266122)).micros); try testing.expectEqual(1801796752202729, (try DateTime.initUTC(2027, 2, 5, 3, 5, 52, 202729)).micros); try testing.expectEqual(-21458729756349585, (try DateTime.initUTC(1289, 12, 31, 1, 44, 3, 650415)).micros); try testing.expectEqual(27431277767015263, (try DateTime.initUTC(2839, 4, 6, 15, 22, 47, 15263)).micros); try testing.expectEqual(-11932647633976328, (try DateTime.initUTC(1591, 11, 14, 15, 39, 26, 23672)).micros); try testing.expectEqual(11561116817530249, (try DateTime.initUTC(2336, 5, 11, 5, 20, 17, 530249)).micros); try testing.expectEqual(-20238374988448844, (try DateTime.initUTC(1328, 9, 2, 13, 10, 11, 551156)).micros); try testing.expectEqual(17825448287939368, (try DateTime.initUTC(2534, 11, 13, 1, 24, 47, 939368)).micros); try testing.expectEqual(-16551182110752962, (try DateTime.initUTC(1445, 7, 7, 9, 24, 49, 247038)).micros); try testing.expectEqual(7773488831126355, (try DateTime.initUTC(2216, 5, 1, 22, 27, 11, 126355)).micros); try testing.expectEqual(-17967725644400042, (try DateTime.initUTC(1400, 8, 17, 5, 5, 55, 599958)).micros); try testing.expectEqual(30634276344447791, (try DateTime.initUTC(2940, 10, 5, 9, 12, 24, 447791)).micros); try testing.expectEqual(-3201531339091604, (try DateTime.initUTC(1868, 7, 19, 5, 44, 20, 908396)).micros); try testing.expectEqual(16621702451341054, (try DateTime.initUTC(2496, 9, 19, 19, 34, 11, 341054)).micros); try testing.expectEqual(-12321145808433043, (try DateTime.initUTC(1579, 7, 24, 3, 29, 51, 566957)).micros); try testing.expectEqual(116851935152341, (try DateTime.initUTC(1973, 9, 14, 10, 52, 15, 152341)).micros); try testing.expectEqual(-26516365395395707, (try DateTime.initUTC(1129, 9, 24, 14, 56, 44, 604293)).micros); try testing.expectEqual(29944637164250909, (try DateTime.initUTC(2918, 11, 28, 10, 46, 4, 250909)).micros); try testing.expectEqual(-14268089958574835, (try DateTime.initUTC(1517, 11, 12, 1, 40, 41, 425165)).micros); try testing.expectEqual(10902808879115327, (try DateTime.initUTC(2315, 7, 1, 22, 1, 19, 115327)).micros); try testing.expectEqual(-13675746347719473, (try DateTime.initUTC(1536, 8, 19, 21, 34, 12, 280527)).micros); try testing.expectEqual(9823904882276154, (try DateTime.initUTC(2281, 4, 22, 14, 28, 2, 276154)).micros); try testing.expectEqual(-8027825490751946, (try DateTime.initUTC(1715, 8, 11, 8, 28, 29, 248054)).micros); try testing.expectEqual(8338818189787922, (try DateTime.initUTC(2234, 4, 1, 2, 23, 9, 787922)).micros); try testing.expectEqual(-2417779710874201, (try DateTime.initUTC(1893, 5, 20, 10, 31, 29, 125799)).micros); try testing.expectEqual(15579463520321126, (try DateTime.initUTC(2463, 9, 10, 20, 45, 20, 321126)).micros); try testing.expectEqual(-30111774746323219, (try DateTime.initUTC(1015, 10, 19, 2, 7, 33, 676781)).micros); try testing.expectEqual(8586318907201828, (try DateTime.initUTC(2242, 2, 2, 16, 35, 7, 201828)).micros); try testing.expectEqual(-20727462914538728, (try DateTime.initUTC(1313, 3, 4, 19, 24, 45, 461272)).micros); try testing.expectEqual(12684924982677857, (try DateTime.initUTC(2371, 12, 21, 6, 16, 22, 677857)).micros); try testing.expectEqual(-26995363453933698, (try DateTime.initUTC(1114, 7, 21, 15, 55, 46, 66302)).micros); try testing.expectEqual(5769549719315448, (try DateTime.initUTC(2152, 10, 30, 4, 41, 59, 315448)).micros); try testing.expectEqual(-9362762735064704, (try DateTime.initUTC(1673, 4, 21, 16, 34, 24, 935296)).micros); try testing.expectEqual(5196087673076825, (try DateTime.initUTC(2134, 8, 28, 21, 41, 13, 76825)).micros); try testing.expectEqual(-10198286600499296, (try DateTime.initUTC(1646, 10, 30, 6, 36, 39, 500704)).micros); try testing.expectEqual(19333137979539125, (try DateTime.initUTC(2582, 8, 23, 4, 6, 19, 539125)).micros); try testing.expectEqual(-18867539824804327, (try DateTime.initUTC(1372, 2, 10, 16, 42, 55, 195673)).micros); try testing.expectEqual(14853031249581056, (try DateTime.initUTC(2440, 9, 3, 2, 0, 49, 581056)).micros); try testing.expectEqual(-1356282109230506, (try DateTime.initUTC(1927, 1, 9, 6, 58, 10, 769494)).micros); try testing.expectEqual(15713222018105813, (try DateTime.initUTC(2467, 12, 6, 23, 53, 38, 105813)).micros); try testing.expectEqual(-12693041975378709, (try DateTime.initUTC(1567, 10, 10, 19, 0, 24, 621291)).micros); try testing.expectEqual(29394313298789588, (try DateTime.initUTC(2901, 6, 20, 23, 1, 38, 789588)).micros); try testing.expectEqual(-10583952098364782, (try DateTime.initUTC(1634, 8, 10, 13, 18, 21, 635218)).micros); try testing.expectEqual(22418800474726154, (try DateTime.initUTC(2680, 6, 3, 20, 34, 34, 726154)).micros); try testing.expectEqual(-13067278028607441, (try DateTime.initUTC(1555, 12, 1, 8, 32, 51, 392559)).micros); try testing.expectEqual(22348003126725817, (try DateTime.initUTC(2678, 3, 7, 10, 38, 46, 725817)).micros); try testing.expectEqual(-11101998054915852, (try DateTime.initUTC(1618, 3, 11, 15, 39, 5, 84148)).micros); try testing.expectEqual(30004645932503986, (try DateTime.initUTC(2920, 10, 22, 23, 52, 12, 503986)).micros); try testing.expectEqual(-27551013013624622, (try DateTime.initUTC(1096, 12, 10, 12, 49, 46, 375378)).micros); try testing.expectEqual(10162791607756167, (try DateTime.initUTC(2292, 1, 17, 21, 40, 7, 756167)).micros); try testing.expectEqual(-31309636417799549, (try DateTime.initUTC(977, 11, 1, 22, 46, 22, 200451)).micros); try testing.expectEqual(9816298180956872, (try DateTime.initUTC(2281, 1, 24, 13, 29, 40, 956872)).micros); try testing.expectEqual(-13248552913008079, (try DateTime.initUTC(1550, 3, 4, 6, 24, 46, 991921)).micros); try testing.expectEqual(24898184818866845, (try DateTime.initUTC(2758, 12, 29, 10, 26, 58, 866845)).micros); try testing.expectEqual(-10721424878768860, (try DateTime.initUTC(1630, 4, 2, 10, 25, 21, 231140)).micros); try testing.expectEqual(3556757075942051, (try DateTime.initUTC(2082, 9, 16, 4, 4, 35, 942051)).micros); try testing.expectEqual(-9515936853544912, (try DateTime.initUTC(1668, 6, 13, 20, 12, 26, 455088)).micros); try testing.expectEqual(23236928933459964, (try DateTime.initUTC(2706, 5, 8, 22, 28, 53, 459964)).micros); try testing.expectEqual(-5811784886171477, (try DateTime.initUTC(1785, 10, 30, 23, 18, 33, 828523)).micros); try testing.expectEqual(27342496921109542, (try DateTime.initUTC(2836, 6, 13, 2, 2, 1, 109542)).micros); try testing.expectEqual(-25369943235288340, (try DateTime.initUTC(1166, 1, 22, 9, 32, 44, 711660)).micros); try testing.expectEqual(10054378230055484, (try DateTime.initUTC(2288, 8, 11, 2, 50, 30, 55484)).micros); try testing.expectEqual(-10826899878642792, (try DateTime.initUTC(1626, 11, 28, 15, 48, 41, 357208)).micros); } test "DateTime: now" { const dt = DateTime.now(); try testing.expectDelta(std.time.microTimestamp(), dt.micros, 1000); } test "DateTime: date" { try testing.expectEqual(Date{ .year = 2023, .month = 11, .day = 25 }, (try DateTime.fromUnix(1700886257, .seconds)).date()); try testing.expectEqual(Date{ .year = 2023, .month = 11, .day = 25 }, (try DateTime.fromUnix(1700886257655, .milliseconds)).date()); try testing.expectEqual(Date{ .year = 2023, .month = 11, .day = 25 }, (try DateTime.fromUnix(1700886257655392, .microseconds)).date()); try testing.expectEqual(Date{ .year = 1970, .month = 1, .day = 1 }, (try DateTime.fromUnix(0, .milliseconds)).date()); // GO: // for i := 0; i < 100; i++ { // us := rand.Int63n(31536000000000000) // if i%2 == 1 { // us = -us // } // date := time.UnixMicro(us).UTC() // fmt.Printf("\ttry testing.expectEqual(Date{.year = %d, .month = %d, .day = %d}, DateTime.fromUnix(%d, .seconds).date());\n", date.Year(), date.Month(), date.Day(), date.Unix()) // } try testing.expectEqual(Date{ .year = 2438, .month = 8, .day = 8 }, (try DateTime.fromUnix(14787635606, .seconds)).date()); try testing.expectEqual(Date{ .year = 1290, .month = 10, .day = 9 }, (try DateTime.fromUnix(-21434368940, .seconds)).date()); try testing.expectEqual(Date{ .year = 2769, .month = 12, .day = 3 }, (try DateTime.fromUnix(25243136028, .seconds)).date()); try testing.expectEqual(Date{ .year = 1437, .month = 6, .day = 30 }, (try DateTime.fromUnix(-16804239664, .seconds)).date()); try testing.expectEqual(Date{ .year = 2752, .month = 4, .day = 7 }, (try DateTime.fromUnix(24685876670, .seconds)).date()); try testing.expectEqual(Date{ .year = 1484, .month = 1, .day = 29 }, (try DateTime.fromUnix(-15334209737, .seconds)).date()); try testing.expectEqual(Date{ .year = 2300, .month = 1, .day = 4 }, (try DateTime.fromUnix(10414107497, .seconds)).date()); try testing.expectEqual(Date{ .year = 1520, .month = 3, .day = 27 }, (try DateTime.fromUnix(-14193188705, .seconds)).date()); try testing.expectEqual(Date{ .year = 2628, .month = 11, .day = 21 }, (try DateTime.fromUnix(20792540664, .seconds)).date()); try testing.expectEqual(Date{ .year = 1807, .month = 2, .day = 21 }, (try DateTime.fromUnix(-5139411928, .seconds)).date()); try testing.expectEqual(Date{ .year = 2249, .month = 12, .day = 12 }, (try DateTime.fromUnix(8834245007, .seconds)).date()); try testing.expectEqual(Date{ .year = 1694, .month = 11, .day = 17 }, (try DateTime.fromUnix(-8681990253, .seconds)).date()); try testing.expectEqual(Date{ .year = 2725, .month = 6, .day = 10 }, (try DateTime.fromUnix(23839369640, .seconds)).date()); try testing.expectEqual(Date{ .year = 1947, .month = 2, .day = 16 }, (try DateTime.fromUnix(-721811319, .seconds)).date()); try testing.expectEqual(Date{ .year = 2293, .month = 9, .day = 28 }, (try DateTime.fromUnix(10216323340, .seconds)).date()); try testing.expectEqual(Date{ .year = 1614, .month = 8, .day = 12 }, (try DateTime.fromUnix(-11214942944, .seconds)).date()); try testing.expectEqual(Date{ .year = 2923, .month = 6, .day = 24 }, (try DateTime.fromUnix(30088834422, .seconds)).date()); try testing.expectEqual(Date{ .year = 1120, .month = 4, .day = 16 }, (try DateTime.fromUnix(-26814276389, .seconds)).date()); try testing.expectEqual(Date{ .year = 2035, .month = 12, .day = 9 }, (try DateTime.fromUnix(2080850037, .seconds)).date()); try testing.expectEqual(Date{ .year = 1167, .month = 1, .day = 15 }, (try DateTime.fromUnix(-25338977309, .seconds)).date()); try testing.expectEqual(Date{ .year = 2665, .month = 4, .day = 15 }, (try DateTime.fromUnix(21941133655, .seconds)).date()); try testing.expectEqual(Date{ .year = 1375, .month = 6, .day = 18 }, (try DateTime.fromUnix(-18761787336, .seconds)).date()); try testing.expectEqual(Date{ .year = 2189, .month = 6, .day = 13 }, (try DateTime.fromUnix(6925211914, .seconds)).date()); try testing.expectEqual(Date{ .year = 1938, .month = 1, .day = 12 }, (try DateTime.fromUnix(-1008879186, .seconds)).date()); try testing.expectEqual(Date{ .year = 2556, .month = 6, .day = 9 }, (try DateTime.fromUnix(18506255391, .seconds)).date()); try testing.expectEqual(Date{ .year = 1294, .month = 10, .day = 29 }, (try DateTime.fromUnix(-21306371902, .seconds)).date()); try testing.expectEqual(Date{ .year = 2330, .month = 3, .day = 19 }, (try DateTime.fromUnix(11367189469, .seconds)).date()); try testing.expectEqual(Date{ .year = 1696, .month = 5, .day = 22 }, (try DateTime.fromUnix(-8634251099, .seconds)).date()); try testing.expectEqual(Date{ .year = 2759, .month = 5, .day = 14 }, (try DateTime.fromUnix(24909971092, .seconds)).date()); try testing.expectEqual(Date{ .year = 1641, .month = 1, .day = 31 }, (try DateTime.fromUnix(-10379518549, .seconds)).date()); try testing.expectEqual(Date{ .year = 2451, .month = 6, .day = 26 }, (try DateTime.fromUnix(15194147684, .seconds)).date()); try testing.expectEqual(Date{ .year = 1962, .month = 1, .day = 4 }, (try DateTime.fromUnix(-252197440, .seconds)).date()); try testing.expectEqual(Date{ .year = 2883, .month = 11, .day = 15 }, (try DateTime.fromUnix(28839089617, .seconds)).date()); try testing.expectEqual(Date{ .year = 1587, .month = 8, .day = 5 }, (try DateTime.fromUnix(-12067604792, .seconds)).date()); try testing.expectEqual(Date{ .year = 2724, .month = 5, .day = 28 }, (try DateTime.fromUnix(23806729201, .seconds)).date()); try testing.expectEqual(Date{ .year = 1043, .month = 2, .day = 25 }, (try DateTime.fromUnix(-29248487174, .seconds)).date()); try testing.expectEqual(Date{ .year = 2927, .month = 3, .day = 9 }, (try DateTime.fromUnix(30205844459, .seconds)).date()); try testing.expectEqual(Date{ .year = 1451, .month = 6, .day = 16 }, (try DateTime.fromUnix(-16363722083, .seconds)).date()); try testing.expectEqual(Date{ .year = 2145, .month = 1, .day = 21 }, (try DateTime.fromUnix(5524305523, .seconds)).date()); try testing.expectEqual(Date{ .year = 1497, .month = 10, .day = 31 }, (try DateTime.fromUnix(-14900125085, .seconds)).date()); try testing.expectEqual(Date{ .year = 2162, .month = 4, .day = 1 }, (try DateTime.fromUnix(6066812142, .seconds)).date()); try testing.expectEqual(Date{ .year = 1738, .month = 8, .day = 12 }, (try DateTime.fromUnix(-7301852750, .seconds)).date()); try testing.expectEqual(Date{ .year = 2100, .month = 2, .day = 7 }, (try DateTime.fromUnix(4105665807, .seconds)).date()); try testing.expectEqual(Date{ .year = 1847, .month = 9, .day = 29 }, (try DateTime.fromUnix(-3858020808, .seconds)).date()); try testing.expectEqual(Date{ .year = 2370, .month = 9, .day = 19 }, (try DateTime.fromUnix(12645416176, .seconds)).date()); try testing.expectEqual(Date{ .year = 1292, .month = 7, .day = 8 }, (try DateTime.fromUnix(-21379166225, .seconds)).date()); try testing.expectEqual(Date{ .year = 2931, .month = 12, .day = 19 }, (try DateTime.fromUnix(30356691249, .seconds)).date()); try testing.expectEqual(Date{ .year = 1064, .month = 5, .day = 12 }, (try DateTime.fromUnix(-28579189254, .seconds)).date()); try testing.expectEqual(Date{ .year = 2295, .month = 5, .day = 13 }, (try DateTime.fromUnix(10267494406, .seconds)).date()); try testing.expectEqual(Date{ .year = 1449, .month = 12, .day = 4 }, (try DateTime.fromUnix(-16411941423, .seconds)).date()); try testing.expectEqual(Date{ .year = 2565, .month = 1, .day = 16 }, (try DateTime.fromUnix(18777760055, .seconds)).date()); try testing.expectEqual(Date{ .year = 1968, .month = 6, .day = 25 }, (try DateTime.fromUnix(-47882241, .seconds)).date()); try testing.expectEqual(Date{ .year = 2817, .month = 5, .day = 9 }, (try DateTime.fromUnix(26739900891, .seconds)).date()); try testing.expectEqual(Date{ .year = 1334, .month = 7, .day = 16 }, (try DateTime.fromUnix(-20053254809, .seconds)).date()); try testing.expectEqual(Date{ .year = 2945, .month = 4, .day = 24 }, (try DateTime.fromUnix(30777844895, .seconds)).date()); try testing.expectEqual(Date{ .year = 1930, .month = 2, .day = 27 }, (try DateTime.fromUnix(-1257362995, .seconds)).date()); try testing.expectEqual(Date{ .year = 2768, .month = 10, .day = 19 }, (try DateTime.fromUnix(25207675701, .seconds)).date()); try testing.expectEqual(Date{ .year = 1372, .month = 6, .day = 12 }, (try DateTime.fromUnix(-18856904218, .seconds)).date()); try testing.expectEqual(Date{ .year = 2603, .month = 8, .day = 29 }, (try DateTime.fromUnix(19996315706, .seconds)).date()); try testing.expectEqual(Date{ .year = 1201, .month = 4, .day = 7 }, (try DateTime.fromUnix(-24258926407, .seconds)).date()); try testing.expectEqual(Date{ .year = 2466, .month = 4, .day = 16 }, (try DateTime.fromUnix(15661407305, .seconds)).date()); try testing.expectEqual(Date{ .year = 1513, .month = 5, .day = 7 }, (try DateTime.fromUnix(-14410616341, .seconds)).date()); try testing.expectEqual(Date{ .year = 2619, .month = 9, .day = 11 }, (try DateTime.fromUnix(20502308837, .seconds)).date()); try testing.expectEqual(Date{ .year = 1501, .month = 5, .day = 13 }, (try DateTime.fromUnix(-14788768973, .seconds)).date()); try testing.expectEqual(Date{ .year = 2765, .month = 11, .day = 19 }, (try DateTime.fromUnix(25115683551, .seconds)).date()); try testing.expectEqual(Date{ .year = 1881, .month = 2, .day = 9 }, (try DateTime.fromUnix(-2805094638, .seconds)).date()); try testing.expectEqual(Date{ .year = 2253, .month = 4, .day = 28 }, (try DateTime.fromUnix(8940802800, .seconds)).date()); try testing.expectEqual(Date{ .year = 1941, .month = 11, .day = 23 }, (try DateTime.fromUnix(-886973505, .seconds)).date()); try testing.expectEqual(Date{ .year = 2565, .month = 1, .day = 18 }, (try DateTime.fromUnix(18777963967, .seconds)).date()); try testing.expectEqual(Date{ .year = 1313, .month = 5, .day = 20 }, (try DateTime.fromUnix(-20720877804, .seconds)).date()); try testing.expectEqual(Date{ .year = 2401, .month = 5, .day = 6 }, (try DateTime.fromUnix(13611949193, .seconds)).date()); try testing.expectEqual(Date{ .year = 1146, .month = 11, .day = 2 }, (try DateTime.fromUnix(-25976564837, .seconds)).date()); try testing.expectEqual(Date{ .year = 2115, .month = 6, .day = 11 }, (try DateTime.fromUnix(4589719542, .seconds)).date()); try testing.expectEqual(Date{ .year = 1276, .month = 8, .day = 1 }, (try DateTime.fromUnix(-21882043432, .seconds)).date()); try testing.expectEqual(Date{ .year = 2224, .month = 4, .day = 26 }, (try DateTime.fromUnix(8025468043, .seconds)).date()); try testing.expectEqual(Date{ .year = 1336, .month = 6, .day = 19 }, (try DateTime.fromUnix(-19992405201, .seconds)).date()); try testing.expectEqual(Date{ .year = 2717, .month = 5, .day = 5 }, (try DateTime.fromUnix(23583761778, .seconds)).date()); try testing.expectEqual(Date{ .year = 1222, .month = 3, .day = 15 }, (try DateTime.fromUnix(-23598239244, .seconds)).date()); try testing.expectEqual(Date{ .year = 2841, .month = 8, .day = 29 }, (try DateTime.fromUnix(27506984246, .seconds)).date()); try testing.expectEqual(Date{ .year = 1818, .month = 7, .day = 28 }, (try DateTime.fromUnix(-4778656923, .seconds)).date()); try testing.expectEqual(Date{ .year = 2533, .month = 5, .day = 13 }, (try DateTime.fromUnix(17778031068, .seconds)).date()); try testing.expectEqual(Date{ .year = 1146, .month = 7, .day = 28 }, (try DateTime.fromUnix(-25984946441, .seconds)).date()); try testing.expectEqual(Date{ .year = 2451, .month = 2, .day = 1 }, (try DateTime.fromUnix(15181688532, .seconds)).date()); try testing.expectEqual(Date{ .year = 1091, .month = 8, .day = 28 }, (try DateTime.fromUnix(-27717880960, .seconds)).date()); try testing.expectEqual(Date{ .year = 2168, .month = 4, .day = 12 }, (try DateTime.fromUnix(6257133476, .seconds)).date()); try testing.expectEqual(Date{ .year = 1718, .month = 10, .day = 16 }, (try DateTime.fromUnix(-7927438165, .seconds)).date()); try testing.expectEqual(Date{ .year = 2614, .month = 8, .day = 21 }, (try DateTime.fromUnix(20342724001, .seconds)).date()); try testing.expectEqual(Date{ .year = 1869, .month = 5, .day = 4 }, (try DateTime.fromUnix(-3176499822, .seconds)).date()); try testing.expectEqual(Date{ .year = 2504, .month = 4, .day = 20 }, (try DateTime.fromUnix(16860953121, .seconds)).date()); try testing.expectEqual(Date{ .year = 1401, .month = 5, .day = 2 }, (try DateTime.fromUnix(-17945432544, .seconds)).date()); try testing.expectEqual(Date{ .year = 2467, .month = 8, .day = 2 }, (try DateTime.fromUnix(15702325347, .seconds)).date()); try testing.expectEqual(Date{ .year = 1654, .month = 3, .day = 12 }, (try DateTime.fromUnix(-9965864717, .seconds)).date()); try testing.expectEqual(Date{ .year = 2371, .month = 9, .day = 2 }, (try DateTime.fromUnix(12675412066, .seconds)).date()); try testing.expectEqual(Date{ .year = 1784, .month = 1, .day = 16 }, (try DateTime.fromUnix(-5868249970, .seconds)).date()); try testing.expectEqual(Date{ .year = 2907, .month = 8, .day = 25 }, (try DateTime.fromUnix(29589265328, .seconds)).date()); try testing.expectEqual(Date{ .year = 987, .month = 4, .day = 9 }, (try DateTime.fromUnix(-31011963272, .seconds)).date()); try testing.expectEqual(Date{ .year = 1980, .month = 10, .day = 19 }, (try DateTime.fromUnix(340838803, .seconds)).date()); try testing.expectEqual(Date{ .year = 1386, .month = 5, .day = 18 }, (try DateTime.fromUnix(-18417299412, .seconds)).date()); try testing.expectEqual(Date{ .year = 2622, .month = 2, .day = 5 }, (try DateTime.fromUnix(20578157994, .seconds)).date()); try testing.expectEqual(Date{ .year = 1056, .month = 11, .day = 6 }, (try DateTime.fromUnix(-28816263601, .seconds)).date()); } test "DateTime: time" { // GO: // for i := 0; i < 100; i++ { // us := rand.Int63n(31536000000000000) // if i%2 == 1 { // us = -us // } // date := time.UnixMicro(us).UTC() // fmt.Printf("\ttry testing.expectEqual(Time{.hour = %d, .min = %d, .sec = %d, .micros = %d}, (try DateTime.fromUnix(%d, .microseconds)).time());\n", date.Hour(), date.Minute(), date.Second(), date.Nanosecond()/1000, us) // } try testing.expectEqual(Time{ .hour = 18, .min = 56, .sec = 18, .micros = 38399 }, (try DateTime.fromUnix(6940752978038399, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 14, .min = 10, .sec = 48, .micros = 481799 }, (try DateTime.fromUnix(-15037004951518201, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 13, .min = 49, .sec = 27, .micros = 814723 }, (try DateTime.fromUnix(26507483367814723, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 3, .min = 53, .sec = 47, .micros = 990825 }, (try DateTime.fromUnix(-15290625972009175, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 9, .min = 28, .sec = 54, .micros = 16606 }, (try DateTime.fromUnix(28046078934016606, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 36, .sec = 38, .micros = 380600 }, (try DateTime.fromUnix(-8638640601619400, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 29, .sec = 27, .micros = 109527 }, (try DateTime.fromUnix(26649192567109527, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 23, .min = 54, .sec = 48, .micros = 10233 }, (try DateTime.fromUnix(-24667200311989767, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 5, .min = 44, .sec = 50, .micros = 913226 }, (try DateTime.fromUnix(22200932690913226, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 5, .min = 36, .sec = 19, .micros = 337687 }, (try DateTime.fromUnix(-13186952620662313, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 20, .min = 6, .sec = 37, .micros = 157270 }, (try DateTime.fromUnix(17827416397157270, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 4, .min = 43, .sec = 33, .micros = 871331 }, (try DateTime.fromUnix(-15558635786128669, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 0, .min = 26, .sec = 54, .micros = 557236 }, (try DateTime.fromUnix(23322644814557236, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 7, .min = 38, .sec = 40, .micros = 370732 }, (try DateTime.fromUnix(-1368030079629268, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 2, .min = 31, .sec = 9, .micros = 223691 }, (try DateTime.fromUnix(20164386669223691, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 12, .min = 41, .sec = 23, .micros = 165207 }, (try DateTime.fromUnix(-20761960716834793, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 0, .min = 46, .sec = 49, .micros = 962075 }, (try DateTime.fromUnix(549247609962075, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 2, .min = 7, .sec = 12, .micros = 984678 }, (try DateTime.fromUnix(-11643688367015322, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 11, .min = 32, .sec = 16, .micros = 343799 }, (try DateTime.fromUnix(4022998336343799, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 26, .sec = 54, .micros = 366277 }, (try DateTime.fromUnix(-8557597985633723, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 16, .min = 1, .sec = 4, .micros = 485152 }, (try DateTime.fromUnix(15070896064485152, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 4, .min = 14, .sec = 18, .micros = 923558 }, (try DateTime.fromUnix(-15995389541076442, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 5, .min = 37, .sec = 58, .micros = 948826 }, (try DateTime.fromUnix(16828148278948826, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 6, .min = 52, .sec = 27, .micros = 1770 }, (try DateTime.fromUnix(-30509975252998230, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 0, .min = 32, .sec = 28, .micros = 381047 }, (try DateTime.fromUnix(7813499548381047, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 14, .min = 1, .sec = 49, .micros = 267686 }, (try DateTime.fromUnix(-14265712690732314, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 4, .min = 53, .sec = 23, .micros = 233239 }, (try DateTime.fromUnix(31107646403233239, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 3, .min = 0, .sec = 53, .micros = 292242 }, (try DateTime.fromUnix(-10317099546707758, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 8, .min = 22, .sec = 13, .micros = 966628 }, (try DateTime.fromUnix(11215959733966628, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 32, .sec = 22, .micros = 779813 }, (try DateTime.fromUnix(-15711949657220187, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 1, .min = 6, .sec = 36, .micros = 405828 }, (try DateTime.fromUnix(6872691996405828, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 12, .min = 0, .sec = 55, .micros = 420129 }, (try DateTime.fromUnix(-31068273544579871, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 22, .min = 17, .sec = 6, .micros = 930158 }, (try DateTime.fromUnix(26304473826930158, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 12, .min = 45, .sec = 25, .micros = 203619 }, (try DateTime.fromUnix(-5358482074796381, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 19, .min = 28, .sec = 0, .micros = 476749 }, (try DateTime.fromUnix(9134623680476749, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 11, .min = 58, .sec = 41, .micros = 864572 }, (try DateTime.fromUnix(-29314353678135428, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 6, .min = 19, .sec = 27, .micros = 566937 }, (try DateTime.fromUnix(9005494767566937, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 9, .min = 3, .sec = 17, .micros = 164061 }, (try DateTime.fromUnix(-24631052202835939, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 23, .min = 2, .sec = 41, .micros = 147703 }, (try DateTime.fromUnix(27754959761147703, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 16, .min = 51, .sec = 1, .micros = 710888 }, (try DateTime.fromUnix(-29839475338289112, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 1, .min = 31, .sec = 44, .micros = 244667 }, (try DateTime.fromUnix(13143000704244667, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 14, .min = 40, .sec = 45, .micros = 594500 }, (try DateTime.fromUnix(-27029323154405500, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 3, .min = 28, .sec = 18, .micros = 941443 }, (try DateTime.fromUnix(26929337298941443, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 18, .min = 34, .sec = 26, .micros = 418287 }, (try DateTime.fromUnix(-16849401933581713, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 16, .min = 51, .sec = 12, .micros = 390293 }, (try DateTime.fromUnix(24013471872390293, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 5, .min = 27, .sec = 59, .micros = 116472 }, (try DateTime.fromUnix(-4881839520883528, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 22, .min = 38, .sec = 58, .micros = 829840 }, (try DateTime.fromUnix(28012689538829840, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 13, .min = 31, .sec = 51, .micros = 397163 }, (try DateTime.fromUnix(-14000034488602837, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 16, .min = 25, .sec = 36, .micros = 566333 }, (try DateTime.fromUnix(3819630336566333, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 23, .min = 52, .sec = 35, .micros = 404576 }, (try DateTime.fromUnix(-24790838844595424, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 14, .min = 17, .sec = 56, .micros = 248627 }, (try DateTime.fromUnix(4303462676248627, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 22, .min = 56, .sec = 31, .micros = 445770 }, (try DateTime.fromUnix(-7573827808554230, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 1, .min = 36, .sec = 32, .micros = 60901 }, (try DateTime.fromUnix(12791180192060901, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 4, .min = 12, .sec = 1, .micros = 816276 }, (try DateTime.fromUnix(-29726596078183724, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 25, .sec = 2, .micros = 88680 }, (try DateTime.fromUnix(9072494702088680, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 7, .min = 14, .sec = 18, .micros = 149127 }, (try DateTime.fromUnix(-20968821941850873, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 15, .min = 45, .sec = 55, .micros = 818121 }, (try DateTime.fromUnix(14590424755818121, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 13, .min = 45, .sec = 5, .micros = 544234 }, (try DateTime.fromUnix(-21099694494455766, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 20, .min = 58, .sec = 32, .micros = 361661 }, (try DateTime.fromUnix(27070837112361661, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 18, .min = 42, .sec = 3, .micros = 375293 }, (try DateTime.fromUnix(-22783699076624707, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 15, .min = 5, .sec = 18, .micros = 844868 }, (try DateTime.fromUnix(3924515118844868, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 39, .sec = 15, .micros = 454348 }, (try DateTime.fromUnix(-19519510844545652, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 22, .min = 34, .sec = 57, .micros = 584438 }, (try DateTime.fromUnix(25405223697584438, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 12, .min = 58, .sec = 48, .micros = 604253 }, (try DateTime.fromUnix(-23848167671395747, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 21, .min = 6, .sec = 10, .micros = 130143 }, (try DateTime.fromUnix(9179039170130143, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 11, .min = 40, .sec = 45, .micros = 806457 }, (try DateTime.fromUnix(-10457900354193543, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 5, .min = 32, .sec = 3, .micros = 84471 }, (try DateTime.fromUnix(20206560723084471, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 11, .min = 8, .sec = 48, .micros = 571978 }, (try DateTime.fromUnix(-13147966271428022, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 10, .min = 37, .sec = 9, .micros = 847397 }, (try DateTime.fromUnix(9639599829847397, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 20, .min = 15, .sec = 37, .micros = 731453 }, (try DateTime.fromUnix(-17972509462268547, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 0, .min = 36, .sec = 51, .micros = 658834 }, (try DateTime.fromUnix(23080639011658834, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 3, .min = 6, .sec = 2, .micros = 359939 }, (try DateTime.fromUnix(-13484004837640061, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 1, .min = 24, .sec = 8, .micros = 76822 }, (try DateTime.fromUnix(22642161848076822, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 5, .min = 20, .sec = 47, .micros = 940649 }, (try DateTime.fromUnix(-9576815952059351, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 16, .min = 19, .sec = 30, .micros = 228423 }, (try DateTime.fromUnix(11237847570228423, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 16, .min = 54, .sec = 33, .micros = 913828 }, (try DateTime.fromUnix(-9146156726086172, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 20, .min = 14, .sec = 10, .micros = 663120 }, (try DateTime.fromUnix(12400805650663120, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 15, .min = 22, .sec = 22, .micros = 500411 }, (try DateTime.fromUnix(-13183893457499589, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 18, .min = 42, .sec = 11, .micros = 637021 }, (try DateTime.fromUnix(17415888131637021, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 22, .min = 7, .sec = 43, .micros = 497651 }, (try DateTime.fromUnix(-3828045136502349, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 9, .min = 25, .sec = 22, .micros = 960397 }, (try DateTime.fromUnix(25585406722960397, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 20, .min = 36, .sec = 31, .micros = 312572 }, (try DateTime.fromUnix(-11209202608687428, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 5, .min = 25, .sec = 18, .micros = 104173 }, (try DateTime.fromUnix(7748544318104173, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 11, .min = 23, .sec = 25, .micros = 504363 }, (try DateTime.fromUnix(-22111446994495637, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 19, .min = 48, .sec = 44, .micros = 703684 }, (try DateTime.fromUnix(21347696924703684, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 12, .min = 10, .sec = 21, .micros = 67035 }, (try DateTime.fromUnix(-29976004178932965, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 6, .min = 0, .sec = 55, .micros = 355102 }, (try DateTime.fromUnix(15622869655355102, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 21, .min = 12, .sec = 1, .micros = 574873 }, (try DateTime.fromUnix(-28386384478425127, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 22, .min = 29, .sec = 45, .micros = 886627 }, (try DateTime.fromUnix(27787703385886627, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 8, .min = 43, .sec = 51, .micros = 403514 }, (try DateTime.fromUnix(-591981368596486, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 12, .min = 1, .sec = 19, .micros = 667089 }, (try DateTime.fromUnix(411998479667089, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 14, .min = 15, .sec = 53, .micros = 366760 }, (try DateTime.fromUnix(-29916899046633240, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 19, .min = 31, .sec = 23, .micros = 639485 }, (try DateTime.fromUnix(29847555083639485, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 0, .min = 21, .sec = 29, .micros = 207122 }, (try DateTime.fromUnix(-13356229110792878, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 10, .min = 35, .sec = 51, .micros = 789976 }, (try DateTime.fromUnix(2401353351789976, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 23, .min = 51, .sec = 4, .micros = 23674 }, (try DateTime.fromUnix(-8687002135976326, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 3, .min = 23, .sec = 21, .micros = 985741 }, (try DateTime.fromUnix(7637772201985741, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 22, .min = 3, .sec = 34, .micros = 497666 }, (try DateTime.fromUnix(-22331814985502334, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 15, .sec = 11, .micros = 818441 }, (try DateTime.fromUnix(14544983711818441, .microseconds)).time()); try testing.expectEqual(Time{ .hour = 17, .min = 47, .sec = 39, .micros = 303089 }, (try DateTime.fromUnix(-19977775940696911, .microseconds)).time()); } test "DateTime: parse RFC822" { // try testing.expectError(error.InvalidDateTime, DateTime.parse("", .rfc822)); // try testing.expectError(error.InvalidDateTime, DateTime.parse("nope", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Oth, 01 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Mon , 01 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Mon, 01 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse(" Mon, 1 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Wed, 1 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Wed, 01 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Wed, 01 J 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Wed, 01 Ja 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Wed, 01 Jan 2 10:10 Z", .rfc822)); try testing.expectError(error.InvalidDate, DateTime.parse("Wed, 01 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 10:10 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 1:10 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 a:10 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 1a:10 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 200:10 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:001 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:a Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a: Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:1 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:a Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:999 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:999 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:22", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:22 Z", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:22 X", .rfc822)); try testing.expectError(error.InvalidTime, DateTime.parse("Wed, 01 Jan 20 20:1a:22 ZZ", .rfc822)); { const dt = try DateTime.parse("31 Dec 68 23:59 Z", .rfc822); try testing.expectEqual(3124223940000000, dt.micros); try testing.expectEqual(2068, dt.date().year); try testing.expectEqual(12, dt.date().month); try testing.expectEqual(31, dt.date().day); try testing.expectEqual(23, dt.time().hour); try testing.expectEqual(59, dt.time().min); try testing.expectEqual(0, dt.time().sec); try testing.expectEqual(0, dt.time().micros); } { const dt = try DateTime.parse("Mon, 31 Dec 68 23:59 Z", .rfc822); try testing.expectEqual(3124223940000000, dt.micros); try testing.expectEqual(2068, dt.date().year); try testing.expectEqual(12, dt.date().month); try testing.expectEqual(31, dt.date().day); try testing.expectEqual(23, dt.time().hour); try testing.expectEqual(59, dt.time().min); try testing.expectEqual(0, dt.time().sec); try testing.expectEqual(0, dt.time().micros); } { const dt = try DateTime.parse("01 Jan 69 01:22:03 GMT", .rfc822); try testing.expectEqual(-31531077000000, dt.micros); try testing.expectEqual(1969, dt.date().year); try testing.expectEqual(1, dt.date().month); try testing.expectEqual(1, dt.date().day); try testing.expectEqual(1, dt.time().hour); try testing.expectEqual(22, dt.time().min); try testing.expectEqual(3, dt.time().sec); try testing.expectEqual(0, dt.time().micros); } { const dt = try DateTime.parse("Sat, 18 Jan 2070 01:22:03 GMT", .rfc822); try testing.expectEqual(3157233723000000, dt.micros); try testing.expectEqual(2070, dt.date().year); try testing.expectEqual(1, dt.date().month); try testing.expectEqual(18, dt.date().day); try testing.expectEqual(1, dt.time().hour); try testing.expectEqual(22, dt.time().min); try testing.expectEqual(3, dt.time().sec); try testing.expectEqual(0, dt.time().micros); } } test "DateTime: parse RFC3339" { { const dt = try DateTime.parse("-3221-01-02T03:04:05Z", .rfc3339); try testing.expectEqual(-163812056155000000, dt.micros); try testing.expectEqual(-3221, dt.date().year); try testing.expectEqual(1, dt.date().month); try testing.expectEqual(2, dt.date().day); try testing.expectEqual(3, dt.time().hour); try testing.expectEqual(4, dt.time().min); try testing.expectEqual(5, dt.time().sec); try testing.expectEqual(0, dt.time().micros); } { const dt = try DateTime.parse("0001-02-03T04:05:06.789+00:00", .rfc3339); try testing.expectEqual(-62132730893211000, dt.micros); try testing.expectEqual(1, dt.date().year); try testing.expectEqual(2, dt.date().month); try testing.expectEqual(3, dt.date().day); try testing.expectEqual(4, dt.time().hour); try testing.expectEqual(5, dt.time().min); try testing.expectEqual(6, dt.time().sec); try testing.expectEqual(789000, dt.time().micros); } { const dt = try DateTime.parse("5000-12-31T23:59:58.987654321Z", .rfc3339); try testing.expectEqual(95649119998987654, dt.micros); try testing.expectEqual(5000, dt.date().year); try testing.expectEqual(12, dt.date().month); try testing.expectEqual(31, dt.date().day); try testing.expectEqual(23, dt.time().hour); try testing.expectEqual(59, dt.time().min); try testing.expectEqual(58, dt.time().sec); try testing.expectEqual(987654, dt.time().micros); } { // invalid format try testing.expectError(error.InvalidDate, DateTime.parse("", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023/01-02T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-01/02T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDateTime, DateTime.parse("0001-01-01 T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDateTime, DateTime.parse("0001-01-01t00:00Z", .rfc3339)); try testing.expectError(error.InvalidDateTime, DateTime.parse("0001-01-01 00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-1-02T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-01-2T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("9-01-2T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("99-01-2T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("999-01-2T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("-999-01-2T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("-1-01-2T00:00Z", .rfc3339)); } // date portion is ISO8601 try testing.expectError(error.InvalidDate, DateTime.parse("20230102T23:59:58.987654321Z", .rfc3339)); { // invalid month try testing.expectError(error.InvalidDate, DateTime.parse("2023-00-22T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-0A-22T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-13-22T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-99-22T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("-2023-00-22T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("-2023-13-22T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("-2023-99-22T00:00Z", .rfc3339)); } { // invalid day try testing.expectError(error.InvalidDate, DateTime.parse("2023-01-00T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-01-32T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-02-29T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-03-32T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-04-31T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-05-32T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-06-31T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-07-32T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-08-32T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-09-31T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-10-32T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-11-31T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2023-12-32T00:00Z", .rfc3339)); } { // valid (max day) try testing.expectEqual(1675123200000000, (try DateTime.parse("2023-01-31T00:00Z", .rfc3339)).micros); try testing.expectEqual(1677542400000000, (try DateTime.parse("2023-02-28T00:00Z", .rfc3339)).micros); try testing.expectEqual(1680220800000000, (try DateTime.parse("2023-03-31T00:00Z", .rfc3339)).micros); try testing.expectEqual(1682812800000000, (try DateTime.parse("2023-04-30T00:00Z", .rfc3339)).micros); try testing.expectEqual(1685491200000000, (try DateTime.parse("2023-05-31T00:00Z", .rfc3339)).micros); try testing.expectEqual(1688083200000000, (try DateTime.parse("2023-06-30T00:00Z", .rfc3339)).micros); try testing.expectEqual(1690761600000000, (try DateTime.parse("2023-07-31T00:00Z", .rfc3339)).micros); try testing.expectEqual(1693440000000000, (try DateTime.parse("2023-08-31T00:00Z", .rfc3339)).micros); try testing.expectEqual(1696032000000000, (try DateTime.parse("2023-09-30T00:00Z", .rfc3339)).micros); try testing.expectEqual(1698710400000000, (try DateTime.parse("2023-10-31T00:00Z", .rfc3339)).micros); try testing.expectEqual(1701302400000000, (try DateTime.parse("2023-11-30T00:00Z", .rfc3339)).micros); try testing.expectEqual(1703980800000000, (try DateTime.parse("2023-12-31T00:00Z", .rfc3339)).micros); } { // leap years try testing.expectEqual(951782400000000, (try DateTime.parse("2000-02-29T00:00Z", .rfc3339)).micros); try testing.expectEqual(13574563200000000, (try DateTime.parse("2400-02-29T00:00Z", .rfc3339)).micros); try testing.expectEqual(1330473600000000, (try DateTime.parse("2012-02-29T00:00Z", .rfc3339)).micros); try testing.expectEqual(1709164800000000, (try DateTime.parse("2024-02-29T00:00Z", .rfc3339)).micros); try testing.expectError(error.InvalidDate, DateTime.parse("2000-02-30T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2400-02-30T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2012-02-30T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2024-02-30T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2100-02-29T00:00Z", .rfc3339)); try testing.expectError(error.InvalidDate, DateTime.parse("2200-02-29T00:00Z", .rfc3339)); } { // invalid time try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T01:00:", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T1:00:00", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T10:1:00", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T10:11:4", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T10:20:30.", .rfc3339)); try testing.expectError(error.InvalidDateTime, DateTime.parse("2023-10-10T10:20:30.a", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T10:20:30.1234567899", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T24:00:00", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T00:60:00", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T00:00:60", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T0a:00:00", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T00:0a:00", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T00:00:0a", .rfc3339)); try testing.expectError(error.InvalidTime, DateTime.parse("2023-10-10T00/00:00", .rfc3339)); try testing.expectError(error.InvalidDateTime, DateTime.parse("2023-10-10T00:00 00", .rfc3339)); } } test "DateTime: json" { { // DateTime, time no fraction const dt = try DateTime.parse("2023-09-22T23:59:02Z", .rfc3339); const out = try std.json.Stringify.valueAlloc(testing.allocator, dt, .{}); defer testing.allocator.free(out); try testing.expectString("\"2023-09-22T23:59:02Z\"", out); } { // time, milliseconds only const dt = try DateTime.parse("2023-09-22T07:09:32.202Z", .rfc3339); const out = try std.json.Stringify.valueAlloc(testing.allocator, dt, .{}); defer testing.allocator.free(out); try testing.expectString("\"2023-09-22T07:09:32.202Z\"", out); } { // time, micros const dt = try DateTime.parse("-0004-12-03T01:02:03.123456Z", .rfc3339); const out = try std.json.Stringify.valueAlloc(testing.allocator, dt, .{}); defer testing.allocator.free(out); try testing.expectString("\"-0004-12-03T01:02:03.123456Z\"", out); } { // parse const ts = try std.json.parseFromSlice(TestStruct, testing.allocator, "{\"datetime\":\"2023-09-22T07:09:32.202Z\"}", .{}); defer ts.deinit(); try testing.expectEqual(try DateTime.parse("2023-09-22T07:09:32.202Z", .rfc3339), ts.value.datetime.?); } } test "DateTime: format" { { var buf: [30]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{try DateTime.initUTC(2023, 5, 22, 23, 59, 59, 0)}); try testing.expectString("2023-05-22T23:59:59Z", out); } { var buf: [30]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{try DateTime.initUTC(2023, 5, 22, 8, 9, 10, 12)}); try testing.expectString("2023-05-22T08:09:10.000012Z", out); } { var buf: [30]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{try DateTime.initUTC(2023, 5, 22, 8, 9, 10, 123)}); try testing.expectString("2023-05-22T08:09:10.000123Z", out); } { var buf: [30]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{try DateTime.initUTC(2023, 5, 22, 8, 9, 10, 1234)}); try testing.expectString("2023-05-22T08:09:10.001234Z", out); } { var buf: [30]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{try DateTime.initUTC(-102, 12, 9, 8, 9, 10, 12345)}); try testing.expectString("-0102-12-09T08:09:10.012345Z", out); } { var buf: [30]u8 = undefined; const out = try std.fmt.bufPrint(&buf, "{f}", .{try DateTime.initUTC(-102, 12, 9, 8, 9, 10, 123456)}); try testing.expectString("-0102-12-09T08:09:10.123456Z", out); } } test "DateTime: order" { { const a = try DateTime.initUTC(2023, 11, 23, 19, 17, 22, 101002); const b = try DateTime.initUTC(2023, 11, 23, 19, 17, 22, 101002); try testing.expectEqual(std.math.Order.eq, a.order(b)); } { const a = try DateTime.initUTC(2023, 5, 22, 12, 59, 2, 492); const b = try DateTime.initUTC(2022, 5, 22, 23, 59, 2, 492); try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = try DateTime.initUTC(2022, 6, 22, 23, 59, 2, 492); const b = try DateTime.initUTC(2022, 5, 22, 23, 33, 2, 492); try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = try DateTime.initUTC(2023, 5, 23, 23, 59, 2, 492); const b = try DateTime.initUTC(2022, 5, 22, 23, 59, 11, 492); try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = try DateTime.initUTC(2023, 11, 23, 20, 17, 22, 101002); const b = try DateTime.initUTC(2023, 11, 23, 19, 17, 22, 101002); try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = try DateTime.initUTC(2023, 11, 23, 19, 18, 22, 101002); const b = try DateTime.initUTC(2023, 11, 23, 19, 17, 22, 101002); try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = try DateTime.initUTC(2023, 11, 23, 19, 17, 23, 101002); const b = try DateTime.initUTC(2023, 11, 23, 19, 17, 22, 101002); try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } { const a = try DateTime.initUTC(2023, 11, 23, 19, 17, 22, 101003); const b = try DateTime.initUTC(2023, 11, 23, 19, 17, 22, 101002); try testing.expectEqual(std.math.Order.gt, a.order(b)); try testing.expectEqual(std.math.Order.lt, b.order(a)); } } test "DateTime: unix" { { const dt = try DateTime.initUTC(-4322, 1, 1, 0, 0, 0, 0); try testing.expectEqual(-198556272000, dt.unix(.seconds)); try testing.expectEqual(-198556272000000, dt.unix(.milliseconds)); try testing.expectEqual(-198556272000000000, dt.unix(.microseconds)); } { const dt = try DateTime.initUTC(1970, 1, 1, 0, 0, 0, 0); try testing.expectEqual(0, dt.unix(.seconds)); try testing.expectEqual(0, dt.unix(.milliseconds)); try testing.expectEqual(0, dt.unix(.microseconds)); } { const dt = try DateTime.initUTC(2023, 11, 24, 12, 6, 14, 918000); try testing.expectEqual(1700827574, dt.unix(.seconds)); try testing.expectEqual(1700827574918, dt.unix(.milliseconds)); try testing.expectEqual(1700827574918000, dt.unix(.microseconds)); } // microseconds // GO: // for i := 0; i < 50; i++ { // us := rand.Int63n(3153600000000000) // if i%2 == 1 { // us = -us // } // date := time.UnixMicro(us).UTC() // fmt.Printf("\ttry testing.expectEqual(%d, (try DateTime.parse(\"%s\", .rfc3339)).unix(.microseconds));\n", us, date.Format(time.RFC3339Nano)) // } try testing.expectEqual(2568689002670356, (try DateTime.parse("2051-05-26T04:43:22.670356Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2994122503199268, (try DateTime.parse("1875-02-13T19:18:16.800732Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2973860981156244, (try DateTime.parse("2064-03-27T16:29:41.156244Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2122539648627924, (try DateTime.parse("1902-09-28T13:39:11.372076Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1440540448439442, (try DateTime.parse("2015-08-25T22:07:28.439442Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-843471236299718, (try DateTime.parse("1943-04-10T14:26:03.700282Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2428009970341301, (try DateTime.parse("2046-12-09T23:12:50.341301Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-861640488391156, (try DateTime.parse("1942-09-12T07:25:11.608844Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(107457228254516, (try DateTime.parse("1973-05-28T17:13:48.254516Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-858997335483954, (try DateTime.parse("1942-10-12T21:37:44.516046Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1879201014676957, (try DateTime.parse("2029-07-20T00:16:54.676957Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2779215184508509, (try DateTime.parse("1881-12-06T03:46:55.491491Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(790920073212180, (try DateTime.parse("1995-01-24T04:01:13.21218Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-1986764905311346, (try DateTime.parse("1907-01-17T00:51:34.688654Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1567001594851223, (try DateTime.parse("2019-08-28T14:13:14.851223Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2786308994565191, (try DateTime.parse("1881-09-15T01:16:45.434809Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1190930851203854, (try DateTime.parse("2007-09-27T22:07:31.203854Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-13894507787609, (try DateTime.parse("1969-07-24T04:24:52.212391Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1283185581222987, (try DateTime.parse("2010-08-30T16:26:21.222987Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-3080071240438154, (try DateTime.parse("1872-05-25T00:39:19.561846Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(3091078494301752, (try DateTime.parse("2067-12-14T08:54:54.301752Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2788286096253476, (try DateTime.parse("1881-08-23T04:05:03.746524Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1226140349962650, (try DateTime.parse("2008-11-08T10:32:29.96265Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-173789078990530, (try DateTime.parse("1964-06-29T13:15:21.00947Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2202006978733437, (try DateTime.parse("2039-10-12T04:36:18.733437Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-1957390566907891, (try DateTime.parse("1907-12-23T00:23:53.092109Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2704228013874812, (try DateTime.parse("2055-09-10T22:26:53.874812Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2162891323622724, (try DateTime.parse("1901-06-18T12:51:16.377276Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2985526644225853, (try DateTime.parse("2064-08-09T16:57:24.225853Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2714126911982044, (try DateTime.parse("1883-12-29T11:51:28.017956Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1389358847381035, (try DateTime.parse("2014-01-10T13:00:47.381035Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2599632972496238, (try DateTime.parse("1887-08-15T15:43:47.503762Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2842567982275671, (try DateTime.parse("2060-01-29T02:13:02.275671Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2924719405531619, (try DateTime.parse("1877-04-27T01:56:34.468381Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(929389345478708, (try DateTime.parse("1999-06-14T19:42:25.478708Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2928161617689577, (try DateTime.parse("1877-03-18T05:46:22.310423Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1981926664387480, (try DateTime.parse("2032-10-20T23:11:04.38748Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-3077852548046313, (try DateTime.parse("1872-06-19T16:57:31.953687Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(323327680783683, (try DateTime.parse("1980-03-31T05:14:40.783683Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-1282955701919591, (try DateTime.parse("1929-05-06T23:24:58.080409Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(1382921217423641, (try DateTime.parse("2013-10-28T00:46:57.423641Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-1431006940775286, (try DateTime.parse("1924-08-27T10:04:19.224714Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(3074639946025509, (try DateTime.parse("2067-06-07T02:39:06.025509Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2634608860053384, (try DateTime.parse("1886-07-06T20:12:19.946616Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2779915686281386, (try DateTime.parse("2058-02-02T22:48:06.281386Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2016252325938190, (try DateTime.parse("1906-02-09T17:54:34.06181Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(342848400150959, (try DateTime.parse("1980-11-12T03:40:00.150959Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-2645960576992651, (try DateTime.parse("1886-02-25T10:57:03.007349Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(2460926767780856, (try DateTime.parse("2047-12-25T22:46:07.780856Z", .rfc3339)).unix(.microseconds)); try testing.expectEqual(-3072719558320472, (try DateTime.parse("1872-08-18T02:47:21.679528Z", .rfc3339)).unix(.microseconds)); // milliseconds // GO // for i := 0; i < 50; i++ { // us := rand.Int63n(3153600000000000) // if i%2 == 1 { // us = -us // } // date := time.UnixMicro(us).UTC() // fmt.Printf("\ttry testing.expectEqual(%d, (try DateTime.parse(\"%s\", .rfc3339)).unix(.milliseconds));\n", us/1000, date.Format(time.RFC3339Nano)) // } try testing.expectEqual(1397526377500, (try DateTime.parse("2014-04-15T01:46:17.500928Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-586731476093, (try DateTime.parse("1951-05-30T03:02:03.906951Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2626709817261, (try DateTime.parse("2053-03-27T17:36:57.261986Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2699459388451, (try DateTime.parse("1884-06-16T06:10:11.548899Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(187068511670, (try DateTime.parse("1975-12-06T03:28:31.670454Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-785593098555, (try DateTime.parse("1945-02-08T11:41:41.444519Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2482013929293, (try DateTime.parse("2048-08-26T00:18:49.293566Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-39404841784, (try DateTime.parse("1968-10-01T22:12:38.215367Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(1534769380821, (try DateTime.parse("2018-08-20T12:49:40.821612Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1980714497790, (try DateTime.parse("1907-03-28T01:31:42.209908Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(1981870811721, (try DateTime.parse("2032-10-20T07:40:11.721424Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-554657243269, (try DateTime.parse("1952-06-04T08:32:36.730587Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(78531146024, (try DateTime.parse("1972-06-27T22:12:26.024177Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2360798362731, (try DateTime.parse("1895-03-10T22:40:37.268319Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2843392029355, (try DateTime.parse("2060-02-07T15:07:09.355931Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1289360209568, (try DateTime.parse("1929-02-21T20:23:10.431793Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2440116994057, (try DateTime.parse("2047-04-29T02:16:34.057859Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1958937239211, (try DateTime.parse("1907-12-05T02:46:00.788847Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2092930144205, (try DateTime.parse("2036-04-27T17:29:04.205599Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1314934006371, (try DateTime.parse("1928-05-01T20:33:13.628366Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(1987707686213, (try DateTime.parse("2032-12-26T21:01:26.21383Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2863567343704, (try DateTime.parse("1879-04-04T20:37:36.295226Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(1776340450602, (try DateTime.parse("2026-04-16T11:54:10.602059Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-135109264096, (try DateTime.parse("1965-09-20T05:38:55.903281Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(664556549013, (try DateTime.parse("1991-01-22T15:02:29.013079Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1265741428742, (try DateTime.parse("1929-11-22T05:09:31.257333Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(677440942549, (try DateTime.parse("1991-06-20T18:02:22.549734Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-3086845293210, (try DateTime.parse("1872-03-07T14:58:26.789666Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2662366721158, (try DateTime.parse("2054-05-14T10:18:41.158507Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-35310777646, (try DateTime.parse("1968-11-18T07:27:02.353055Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(466748318057, (try DateTime.parse("1984-10-16T04:18:38.057985Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1142849776788, (try DateTime.parse("1933-10-14T13:43:43.211425Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(299657172861, (try DateTime.parse("1979-07-01T06:06:12.86151Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2674956599650, (try DateTime.parse("1885-03-26T20:30:00.34904Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2608306771546, (try DateTime.parse("2052-08-26T17:39:31.546441Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2890194900832, (try DateTime.parse("1878-05-31T16:04:59.167405Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(396552033685, (try DateTime.parse("1982-07-26T17:20:33.68525Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-107099840493, (try DateTime.parse("1966-08-10T10:02:39.506219Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(3003275118291, (try DateTime.parse("2065-03-03T03:05:18.291675Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1827348315834, (try DateTime.parse("1912-02-05T03:14:44.165534Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(276927903561, (try DateTime.parse("1978-10-11T04:25:03.561761Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2769749223625, (try DateTime.parse("1882-03-25T17:12:56.374223Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2626498021199, (try DateTime.parse("2053-03-25T06:47:01.199662Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-1394547124859, (try DateTime.parse("1925-10-23T09:47:55.140254Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(272330504585, (try DateTime.parse("1978-08-18T23:21:44.585364Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2210407675350, (try DateTime.parse("1899-12-15T13:52:04.649158Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(1506546882755, (try DateTime.parse("2017-09-27T21:14:42.755649Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-2320627977264, (try DateTime.parse("1896-06-17T21:07:02.735544Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(2719300156090, (try DateTime.parse("2056-03-03T09:09:16.090337Z", .rfc3339)).unix(.milliseconds)); try testing.expectEqual(-450791776320, (try DateTime.parse("1955-09-19T12:03:43.679144Z", .rfc3339)).unix(.milliseconds)); // seconds // GO // for i := 0; i < 50; i++ { // us := rand.Int63n(3153600000000000) // if i%2 == 1 { // us = -us // } // date := time.UnixMicro(us).UTC() // fmt.Printf("\ttry testing.expectEqual(%d, (try DateTime.parse(\"%s\", .rfc3339)).unix(.milliseconds));\n", us/1000/1000, date.Format(time.RFC3339Nano)) // } try testing.expectEqual(1019355037, (try DateTime.parse("2002-04-21T02:10:37.264298Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2639191098, (try DateTime.parse("1886-05-14T19:21:41.481076Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(552479765, (try DateTime.parse("1987-07-05T10:36:05.374475Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2842270449, (try DateTime.parse("1879-12-07T08:25:50.857157Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2287542812, (try DateTime.parse("2042-06-28T04:33:32.585424Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-1032056861, (try DateTime.parse("1937-04-18T21:32:18.185245Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2294125759, (try DateTime.parse("2042-09-12T09:09:19.324234Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2434666174, (try DateTime.parse("1892-11-05T23:50:25.855342Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2130180824, (try DateTime.parse("2037-07-02T20:53:44.663679Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2088926942, (try DateTime.parse("1903-10-22T14:30:57.110159Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(210188161, (try DateTime.parse("1976-08-29T17:36:01.512348Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-1594811550, (try DateTime.parse("1919-06-19T12:47:29.692995Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(408055212, (try DateTime.parse("1982-12-06T20:40:12.74791Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-763370385, (try DateTime.parse("1945-10-23T16:40:14.54824Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2220686606, (try DateTime.parse("2040-05-15T09:23:26.183323Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-1829267394, (try DateTime.parse("1912-01-13T22:10:05.152891Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(186103622, (try DateTime.parse("1975-11-24T23:27:02.092278Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-104963797, (try DateTime.parse("1966-09-04T03:23:22.379643Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(188664629, (try DateTime.parse("1975-12-24T14:50:29.082285Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-978305356, (try DateTime.parse("1939-01-01T00:30:43.460779Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(1857079750, (try DateTime.parse("2028-11-05T23:29:10.225783Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-1059764722, (try DateTime.parse("1936-06-02T04:54:37.841836Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2931563560, (try DateTime.parse("2062-11-24T03:12:40.682221Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-58861051, (try DateTime.parse("1968-02-19T17:42:28.861019Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2540374023, (try DateTime.parse("2050-07-02T11:27:03.083527Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-369803898, (try DateTime.parse("1958-04-13T20:41:41.391534Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(1150522786, (try DateTime.parse("2006-06-17T05:39:46.776689Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-3094311182, (try DateTime.parse("1871-12-12T05:06:57.955425Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2742945297, (try DateTime.parse("2056-12-02T01:14:57.552041Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-3055421456, (try DateTime.parse("1873-03-06T07:49:03.861761Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(1935913185, (try DateTime.parse("2031-05-07T09:39:45.408961Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-1546803921, (try DateTime.parse("1920-12-26T04:14:38.089431Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2430955251, (try DateTime.parse("2047-01-13T01:20:51.611416Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-1162742133, (try DateTime.parse("1933-02-26T08:04:26.776057Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2820984010, (try DateTime.parse("2059-05-24T06:40:10.9707Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2671779872, (try DateTime.parse("1885-05-02T14:55:27.010415Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(419726969, (try DateTime.parse("1983-04-20T22:49:29.184213Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2886236400, (try DateTime.parse("1878-07-16T11:39:59.700923Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(1091845921, (try DateTime.parse("2004-08-07T02:32:01.949043Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-1345585389, (try DateTime.parse("1927-05-13T02:16:50.807413Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(968555612, (try DateTime.parse("2000-09-10T03:13:32.056103Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-525723150, (try DateTime.parse("1953-05-05T05:47:29.657935Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2179443523, (try DateTime.parse("2039-01-24T00:58:43.238504Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2200838901, (try DateTime.parse("1900-04-05T07:51:38.801707Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(567335109, (try DateTime.parse("1987-12-24T09:05:09.535877Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-714932675, (try DateTime.parse("1947-05-07T07:35:24.863781Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(2735649359, (try DateTime.parse("2056-09-08T14:35:59.483204Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-2386101706, (try DateTime.parse("1894-05-22T01:58:13.445088Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(115985094, (try DateTime.parse("1973-09-04T10:04:54.005266Z", .rfc3339)).unix(.seconds)); try testing.expectEqual(-3046532170, (try DateTime.parse("1873-06-17T05:03:49.260067Z", .rfc3339)).unix(.seconds)); } test "DateTime: limits" { { // min const dt1 = try DateTime.initUTC(-4712, 1, 1, 0, 0, 0, 0); const dt2 = try DateTime.parse("-4712-01-01T00:00:00.000000Z", .rfc3339); const dt3 = try DateTime.fromUnix(-210863520000, .seconds); const dt4 = try DateTime.fromUnix(-210863520000000, .milliseconds); const dt5 = try DateTime.fromUnix(-210863520000000000, .microseconds); for ([_]DateTime{ dt1, dt2, dt3, dt4, dt5 }) |dt| { try testing.expectEqual(-4712, dt.date().year); try testing.expectEqual(1, dt.date().month); try testing.expectEqual(1, dt.date().day); try testing.expectEqual(0, dt.time().hour); try testing.expectEqual(0, dt.time().min); try testing.expectEqual(0, dt.time().sec); try testing.expectEqual(0, dt.time().micros); try testing.expectEqual(-210863520000, dt.unix(.seconds)); try testing.expectEqual(-210863520000000, dt.unix(.milliseconds)); try testing.expectEqual(-210863520000000000, dt.unix(.microseconds)); } } { // max const dt1 = try DateTime.initUTC(9999, 12, 31, 23, 59, 59, 999999); const dt2 = try DateTime.parse("9999-12-31T23:59:59.999999Z", .rfc3339); const dt3 = try DateTime.fromUnix(253402300799, .seconds); const dt4 = try DateTime.fromUnix(253402300799999, .milliseconds); const dt5 = try DateTime.fromUnix(253402300799999999, .microseconds); for ([_]DateTime{ dt1, dt2, dt3, dt4, dt5 }, 0..) |dt, i| { try testing.expectEqual(9999, dt.date().year); try testing.expectEqual(12, dt.date().month); try testing.expectEqual(31, dt.date().day); try testing.expectEqual(23, dt.time().hour); try testing.expectEqual(59, dt.time().min); try testing.expectEqual(59, dt.time().sec); try testing.expectEqual(253402300799, dt.unix(.seconds)); if (i == 2) { try testing.expectEqual(0, dt.time().micros); try testing.expectEqual(253402300799000, dt.unix(.milliseconds)); try testing.expectEqual(253402300799000000, dt.unix(.microseconds)); } else if (i == 3) { try testing.expectEqual(999000, dt.time().micros); try testing.expectEqual(253402300799999, dt.unix(.milliseconds)); try testing.expectEqual(253402300799999000, dt.unix(.microseconds)); } else { try testing.expectEqual(999999, dt.time().micros); try testing.expectEqual(253402300799999, dt.unix(.milliseconds)); try testing.expectEqual(253402300799999999, dt.unix(.microseconds)); } } } } test "DateTime: add" { { // positive var dt = try DateTime.parse("2023-11-26T03:13:46.540234Z", .rfc3339); dt = try dt.add(800, .microseconds); try expectDateTime("2023-11-26T03:13:46.541034Z", dt); dt = try dt.add(950, .milliseconds); try expectDateTime("2023-11-26T03:13:47.491034Z", dt); dt = try dt.add(32, .seconds); try expectDateTime("2023-11-26T03:14:19.491034Z", dt); dt = try dt.add(1489, .minutes); try expectDateTime("2023-11-27T04:03:19.491034Z", dt); dt = try dt.add(6, .days); try expectDateTime("2023-12-03T04:03:19.491034Z", dt); } { // negative var dt = try DateTime.parse("2023-11-26T03:13:46.540234Z", .rfc3339); dt = try dt.add(-800, .microseconds); try expectDateTime("2023-11-26T03:13:46.539434Z", dt); dt = try dt.add(-950, .milliseconds); try expectDateTime("2023-11-26T03:13:45.589434Z", dt); dt = try dt.add(-50, .seconds); try expectDateTime("2023-11-26T03:12:55.589434Z", dt); dt = try dt.add(-1489, .minutes); try expectDateTime("2023-11-25T02:23:55.589434Z", dt); dt = try dt.add(-6, .days); try expectDateTime("2023-11-19T02:23:55.589434Z", dt); } } test "DateTime: sub" { { const a = try DateTime.parse("2023-11-26T03:13:46.540234Z", .rfc3339); const b = try DateTime.parse("2023-11-26T03:13:47.540236Z", .rfc3339); try testing.expectEqual(-1, a.sub(b, .seconds)); try testing.expectEqual(-1000, a.sub(b, .milliseconds)); try testing.expectEqual(-1000002, a.sub(b, .microseconds)); } { const a = try DateTime.parse("2023-11-27T03:13:47.540234Z", .rfc3339); const b = try DateTime.parse("2023-11-26T03:13:47.540234Z", .rfc3339); try testing.expectEqual(86400, a.sub(b, .seconds)); try testing.expectEqual(86400000, a.sub(b, .milliseconds)); try testing.expectEqual(86400000000, a.sub(b, .microseconds)); } } fn expectDateTime(expected: []const u8, dt: DateTime) !void { var buf: [30]u8 = undefined; const actual = try std.fmt.bufPrint(&buf, "{f}", .{dt}); try testing.expectString(expected, actual); } const TestStruct = struct { date: ?Date = null, time: ?Time = null, datetime: ?DateTime = null, }; ================================================ FILE: src/html5ever/Cargo.toml ================================================ [package] name = "litefetch-html5ever" version = "0.1.0" edition = "2021" [lib] name = "litefetch_html5ever" path = "lib.rs" crate-type = ["cdylib", "staticlib"] [dependencies] html5ever = "0.35.0" string_cache = "0.9.0" typed-arena = "2.0.2" tikv-jemallocator = {version = "0.6.0", features = ["stats"]} tikv-jemalloc-ctl = {version = "0.6.0", features = ["stats"]} xml5ever = "0.35.0" [profile.release] lto = true codegen-units = 1 ================================================ FILE: src/html5ever/lib.rs ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. mod sink; mod types; #[cfg(debug_assertions)] #[global_allocator] static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; use std::cell::Cell; use std::os::raw::{c_uchar, c_void}; use types::*; use html5ever::interface::tree_builder::QuirksMode; use html5ever::tendril::{StrTendril, TendrilSink}; use html5ever::{ns, parse_document, parse_fragment, LocalName, ParseOpts, Parser, QualName}; #[no_mangle] pub extern "C" fn html5ever_parse_document( html: *mut c_uchar, len: usize, document: Ref, ctx: Ref, create_element_callback: CreateElementCallback, get_data_callback: GetDataCallback, append_callback: AppendCallback, parse_error_callback: ParseErrorCallback, pop_callback: PopCallback, create_comment_callback: CreateCommentCallback, create_processing_instruction: CreateProcessingInstruction, append_doctype_to_document: AppendDoctypeToDocumentCallback, add_attrs_if_missing_callback: AddAttrsIfMissingCallback, get_template_contents_callback: GetTemplateContentsCallback, remove_from_parent_callback: RemoveFromParentCallback, reparent_children_callback: ReparentChildrenCallback, append_before_sibling_callback: AppendBeforeSiblingCallback, append_based_on_parent_node_callback: AppendBasedOnParentNodeCallback, ) -> () { if html.is_null() || len == 0 { return (); } let arena = typed_arena::Arena::new(); let sink = sink::Sink { ctx: ctx, arena: &arena, document: document, quirks_mode: Cell::new(QuirksMode::NoQuirks), pop_callback: pop_callback, append_callback: append_callback, get_data_callback: get_data_callback, parse_error_callback: parse_error_callback, create_element_callback: create_element_callback, create_comment_callback: create_comment_callback, create_processing_instruction: create_processing_instruction, append_doctype_to_document: append_doctype_to_document, add_attrs_if_missing_callback: add_attrs_if_missing_callback, get_template_contents_callback: get_template_contents_callback, remove_from_parent_callback: remove_from_parent_callback, reparent_children_callback: reparent_children_callback, append_before_sibling_callback: append_before_sibling_callback, append_based_on_parent_node_callback: append_based_on_parent_node_callback, }; let bytes = unsafe { std::slice::from_raw_parts(html, len) }; parse_document(sink, Default::default()) .from_utf8() .one(bytes); } #[no_mangle] pub extern "C" fn html5ever_parse_fragment( html: *mut c_uchar, len: usize, document: Ref, ctx: Ref, create_element_callback: CreateElementCallback, get_data_callback: GetDataCallback, append_callback: AppendCallback, parse_error_callback: ParseErrorCallback, pop_callback: PopCallback, create_comment_callback: CreateCommentCallback, create_processing_instruction: CreateProcessingInstruction, append_doctype_to_document: AppendDoctypeToDocumentCallback, add_attrs_if_missing_callback: AddAttrsIfMissingCallback, get_template_contents_callback: GetTemplateContentsCallback, remove_from_parent_callback: RemoveFromParentCallback, reparent_children_callback: ReparentChildrenCallback, append_before_sibling_callback: AppendBeforeSiblingCallback, append_based_on_parent_node_callback: AppendBasedOnParentNodeCallback, ) -> () { if html.is_null() || len == 0 { return (); } let arena = typed_arena::Arena::new(); let sink = sink::Sink { ctx: ctx, arena: &arena, document: document, quirks_mode: Cell::new(QuirksMode::NoQuirks), pop_callback: pop_callback, append_callback: append_callback, get_data_callback: get_data_callback, parse_error_callback: parse_error_callback, create_element_callback: create_element_callback, create_comment_callback: create_comment_callback, create_processing_instruction: create_processing_instruction, append_doctype_to_document: append_doctype_to_document, add_attrs_if_missing_callback: add_attrs_if_missing_callback, get_template_contents_callback: get_template_contents_callback, remove_from_parent_callback: remove_from_parent_callback, reparent_children_callback: reparent_children_callback, append_before_sibling_callback: append_before_sibling_callback, append_based_on_parent_node_callback: append_based_on_parent_node_callback, }; let bytes = unsafe { std::slice::from_raw_parts(html, len) }; parse_fragment( sink, Default::default(), QualName::new(None, ns!(html), LocalName::from("body")), vec![], // attributes false, // context_element_allows_scripting ) .from_utf8() .one(bytes); } #[no_mangle] pub extern "C" fn html5ever_attribute_iterator_next( c_iter: *const c_void, ) -> CNullable<CAttribute> { let iter: &mut CAttributeIterator = unsafe { &mut *(c_iter as *mut CAttributeIterator) }; let pos = iter.pos; if pos == iter.vec.len() { return CNullable::<CAttribute>::none(); } let attr = &iter.vec[pos]; iter.pos += 1; return CNullable::<CAttribute>::some(CAttribute { name: CQualName::create(&attr.name), value: StringSlice { ptr: attr.value.as_ptr(), len: attr.value.len(), }, }); } #[no_mangle] pub extern "C" fn html5ever_attribute_iterator_count(c_iter: *const c_void) -> usize { let iter: &mut CAttributeIterator = unsafe { &mut *(c_iter as *mut CAttributeIterator) }; return iter.vec.len(); } #[cfg(debug_assertions)] #[repr(C)] pub struct Memory { pub resident: usize, pub allocated: usize, } #[cfg(debug_assertions)] #[no_mangle] pub extern "C" fn html5ever_get_memory_usage() -> Memory { use tikv_jemalloc_ctl::{epoch, stats}; // many statistics are cached and only updated when the epoch is advanced. epoch::advance().unwrap(); return Memory { resident: stats::resident::read().unwrap(), allocated: stats::allocated::read().unwrap(), }; } // Streaming parser API // The Parser type from html5ever implements TendrilSink and supports streaming pub struct StreamingParser { #[allow(dead_code)] arena: Box<typed_arena::Arena<sink::ElementData>>, parser: Box<dyn std::any::Any>, } #[no_mangle] pub extern "C" fn html5ever_streaming_parser_create( document: Ref, ctx: Ref, create_element_callback: CreateElementCallback, get_data_callback: GetDataCallback, append_callback: AppendCallback, parse_error_callback: ParseErrorCallback, pop_callback: PopCallback, create_comment_callback: CreateCommentCallback, create_processing_instruction: CreateProcessingInstruction, append_doctype_to_document: AppendDoctypeToDocumentCallback, add_attrs_if_missing_callback: AddAttrsIfMissingCallback, get_template_contents_callback: GetTemplateContentsCallback, remove_from_parent_callback: RemoveFromParentCallback, reparent_children_callback: ReparentChildrenCallback, append_before_sibling_callback: AppendBeforeSiblingCallback, append_based_on_parent_node_callback: AppendBasedOnParentNodeCallback, ) -> *mut c_void { let arena = Box::new(typed_arena::Arena::new()); // SAFETY: We're creating a self-referential structure here. // The arena is stored in the StreamingParser and lives as long as the parser. // The sink contains a reference to the arena that's valid for the parser's lifetime. let arena_ref: &'static typed_arena::Arena<sink::ElementData> = unsafe { std::mem::transmute(arena.as_ref()) }; let sink = sink::Sink { ctx: ctx, arena: arena_ref, document: document, quirks_mode: Cell::new(QuirksMode::NoQuirks), pop_callback: pop_callback, append_callback: append_callback, get_data_callback: get_data_callback, parse_error_callback: parse_error_callback, create_element_callback: create_element_callback, create_comment_callback: create_comment_callback, create_processing_instruction: create_processing_instruction, append_doctype_to_document: append_doctype_to_document, add_attrs_if_missing_callback: add_attrs_if_missing_callback, get_template_contents_callback: get_template_contents_callback, remove_from_parent_callback: remove_from_parent_callback, reparent_children_callback: reparent_children_callback, append_before_sibling_callback: append_before_sibling_callback, append_based_on_parent_node_callback: append_based_on_parent_node_callback, }; // Create a parser which implements TendrilSink for streaming parsing let parser = parse_document(sink, ParseOpts::default()); let streaming_parser = Box::new(StreamingParser { arena, parser: Box::new(parser), }); return Box::into_raw(streaming_parser) as *mut c_void; } #[no_mangle] pub extern "C" fn html5ever_streaming_parser_feed( parser_ptr: *mut c_void, html: *const c_uchar, len: usize, ) -> i32 { if parser_ptr.is_null() || html.is_null() || len == 0 { return 0; } let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let streaming_parser = unsafe { &mut *(parser_ptr as *mut StreamingParser) }; let bytes = unsafe { std::slice::from_raw_parts(html, len) }; // Convert bytes to UTF-8 string if let Ok(s) = std::str::from_utf8(bytes) { let tendril = StrTendril::from(s); // Feed the chunk to the parser // The Parser implements TendrilSink, so we can call process() on it let parser = streaming_parser .parser .downcast_mut::<Parser<sink::Sink>>() .expect("Invalid parser type"); parser.process(tendril); } })); match result { Ok(_) => 0, // Success Err(_) => -1, // Panic occurred } } #[no_mangle] pub extern "C" fn html5ever_streaming_parser_finish(parser_ptr: *mut c_void) { if parser_ptr.is_null() { return; } let streaming_parser = unsafe { Box::from_raw(parser_ptr as *mut StreamingParser) }; // Extract and finish the parser let parser = streaming_parser .parser .downcast::<Parser<sink::Sink>>() .expect("Invalid parser type"); // Finish consumes the parser, which will call finish() on the sink parser.finish(); // Note: The arena will be dropped here automatically } #[no_mangle] pub extern "C" fn html5ever_streaming_parser_destroy(parser_ptr: *mut c_void) { if parser_ptr.is_null() { return; } // Drop the parser box without finishing // This is for cases where you want to cancel parsing unsafe { let _ = Box::from_raw(parser_ptr as *mut StreamingParser); } } #[no_mangle] pub extern "C" fn xml5ever_parse_document( xml: *mut c_uchar, len: usize, document: Ref, ctx: Ref, create_element_callback: CreateElementCallback, get_data_callback: GetDataCallback, append_callback: AppendCallback, parse_error_callback: ParseErrorCallback, pop_callback: PopCallback, create_comment_callback: CreateCommentCallback, create_processing_instruction: CreateProcessingInstruction, append_doctype_to_document: AppendDoctypeToDocumentCallback, add_attrs_if_missing_callback: AddAttrsIfMissingCallback, get_template_contents_callback: GetTemplateContentsCallback, remove_from_parent_callback: RemoveFromParentCallback, reparent_children_callback: ReparentChildrenCallback, append_before_sibling_callback: AppendBeforeSiblingCallback, append_based_on_parent_node_callback: AppendBasedOnParentNodeCallback, ) -> () { if xml.is_null() || len == 0 { return (); } let arena = typed_arena::Arena::new(); let sink = sink::Sink { ctx: ctx, arena: &arena, document: document, quirks_mode: Cell::new(QuirksMode::NoQuirks), pop_callback: pop_callback, append_callback: append_callback, get_data_callback: get_data_callback, parse_error_callback: parse_error_callback, create_element_callback: create_element_callback, create_comment_callback: create_comment_callback, create_processing_instruction: create_processing_instruction, append_doctype_to_document: append_doctype_to_document, add_attrs_if_missing_callback: add_attrs_if_missing_callback, get_template_contents_callback: get_template_contents_callback, remove_from_parent_callback: remove_from_parent_callback, reparent_children_callback: reparent_children_callback, append_before_sibling_callback: append_before_sibling_callback, append_based_on_parent_node_callback: append_based_on_parent_node_callback, }; let bytes = unsafe { std::slice::from_raw_parts(xml, len) }; xml5ever::driver::parse_document(sink, xml5ever::driver::XmlParseOpts::default()) .from_utf8() .one(bytes); } ================================================ FILE: src/html5ever/sink.rs ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. use std::ptr; use std::cell::Cell; use std::borrow::Cow; use std::os::raw::{c_void}; use crate::types::*; use html5ever::tendril::{StrTendril}; use html5ever::{Attribute, QualName}; use html5ever::interface::tree_builder::{ElementFlags, NodeOrText, QuirksMode, TreeSink}; type Arena<'arena> = &'arena typed_arena::Arena<ElementData>; // Made public so it can be used from lib.rs pub struct ElementData { pub qname: QualName, pub mathml_annotation_xml_integration_point: bool, } impl ElementData { fn new(qname: QualName, flags: ElementFlags) -> Self { return Self { qname: qname, mathml_annotation_xml_integration_point: flags.mathml_annotation_xml_integration_point, }; } } pub struct Sink<'arena> { pub ctx: Ref, pub document: Ref, pub arena: Arena<'arena>, pub quirks_mode: Cell<QuirksMode>, pub pop_callback: PopCallback, pub append_callback: AppendCallback, pub get_data_callback: GetDataCallback, pub parse_error_callback: ParseErrorCallback, pub create_element_callback: CreateElementCallback, pub create_comment_callback: CreateCommentCallback, pub create_processing_instruction: CreateProcessingInstruction, pub append_doctype_to_document: AppendDoctypeToDocumentCallback, pub add_attrs_if_missing_callback: AddAttrsIfMissingCallback, pub get_template_contents_callback: GetTemplateContentsCallback, pub remove_from_parent_callback: RemoveFromParentCallback, pub reparent_children_callback: ReparentChildrenCallback, pub append_before_sibling_callback: AppendBeforeSiblingCallback, pub append_based_on_parent_node_callback: AppendBasedOnParentNodeCallback, } impl<'arena> TreeSink for Sink<'arena> { type Handle = *const c_void; type Output = (); type ElemName<'a> = &'a QualName where Self: 'a; fn finish(self) -> () { return (); } fn parse_error(&self, err: Cow<'static, str>) { unsafe { (self.parse_error_callback)( self.ctx, StringSlice { ptr: err.as_ptr(), len: err.len(), }, ); } } fn get_document(&self) -> *const c_void { return self.document; } fn set_quirks_mode(&self, mode: QuirksMode) { self.quirks_mode.set(mode); } fn same_node(&self, x: &Ref, y: &Ref) -> bool { ptr::eq::<c_void>(*x, *y) } fn elem_name(&self, target: &Ref) -> Self::ElemName<'_> { let opaque = unsafe { (self.get_data_callback)(*target) }; let data = opaque as *mut ElementData; return unsafe { &(*data).qname }; } fn get_template_contents(&self, target: &Ref) -> Ref { unsafe { return (self.get_template_contents_callback)(self.ctx, *target); } } fn is_mathml_annotation_xml_integration_point(&self, target: &Ref) -> bool { let opaque = unsafe { (self.get_data_callback)(*target) }; let data = opaque as *mut ElementData; return unsafe { (*data).mathml_annotation_xml_integration_point }; } fn pop(&self, node: &Ref) { unsafe { (self.pop_callback)(self.ctx, *node); } } fn create_element(&self, name: QualName, attrs: Vec<Attribute>, flags: ElementFlags) -> Ref { let data = self.arena.alloc(ElementData::new(name.clone(), flags)); unsafe { let mut attribute_iterator = CAttributeIterator { vec: attrs, pos: 0 }; return (self.create_element_callback)( self.ctx, data as *mut _ as *mut c_void, CQualName::create(&name), &mut attribute_iterator as *mut _ as *mut c_void, ); } } fn create_comment(&self, txt: StrTendril) -> Ref { let str = StringSlice{ ptr: txt.as_ptr(), len: txt.len()}; unsafe { return (self.create_comment_callback)(self.ctx, str); } } fn create_pi(&self, target: StrTendril, data: StrTendril) -> Ref { let str_target = StringSlice{ ptr: target.as_ptr(), len: target.len()}; let str_data = StringSlice{ ptr: data.as_ptr(), len: data.len()}; unsafe { return (self.create_processing_instruction)(self.ctx, str_target, str_data); } } fn append(&self, parent: &Ref, child: NodeOrText<Ref>) { match child { NodeOrText::AppendText(ref t) => { // The child exists for the duration of the append_callback call, // but sometimes the memory on the Zig side, in append_callback, // is zeroed. If you try to refactor this code a bit, and do: // unsafe { // (self.append_callback)(self.ctx, *parent, CNodeOrText::create(child)); // } // Where CNodeOrText::create returns the property CNodeOrText, // you'll occasionally see that zeroed memory. Makes no sense to // me, but a far as I can tell, this version works. let byte_slice = t.as_ref().as_bytes(); let static_slice: &'static [u8] = unsafe { std::mem::transmute(byte_slice) }; unsafe { (self.append_callback)(self.ctx, *parent, CNodeOrText{ tag: 1, node: ptr::null(), text: StringSlice { ptr: static_slice.as_ptr(), len: static_slice.len()}, }); }; }, NodeOrText::AppendNode(node) => { unsafe { (self.append_callback)(self.ctx, *parent, CNodeOrText{ tag: 0, node: node, text: StringSlice::default() }); }; } } } fn append_before_sibling(&self, sibling: &Ref, child: NodeOrText<Ref>) { match child { NodeOrText::AppendText(ref t) => { let byte_slice = t.as_ref().as_bytes(); let static_slice: &'static [u8] = unsafe { std::mem::transmute(byte_slice) }; unsafe { (self.append_before_sibling_callback)(self.ctx, *sibling, CNodeOrText{ tag: 1, node: ptr::null(), text: StringSlice { ptr: static_slice.as_ptr(), len: static_slice.len()}, }); }; }, NodeOrText::AppendNode(node) => { unsafe { (self.append_before_sibling_callback)(self.ctx, *sibling, CNodeOrText{ tag: 0, node: node, text: StringSlice::default() }); }; } } } fn append_based_on_parent_node( &self, element: &Ref, prev_element: &Ref, child: NodeOrText<Ref>, ) { match child { NodeOrText::AppendText(ref t) => { let byte_slice = t.as_ref().as_bytes(); let static_slice: &'static [u8] = unsafe { std::mem::transmute(byte_slice) }; unsafe { (self.append_based_on_parent_node_callback)(self.ctx, *element, *prev_element, CNodeOrText{ tag: 1, node: ptr::null(), text: StringSlice { ptr: static_slice.as_ptr(), len: static_slice.len()}, }); }; }, NodeOrText::AppendNode(node) => { unsafe { (self.append_based_on_parent_node_callback)(self.ctx, *element, *prev_element, CNodeOrText{ tag: 0, node: node, text: StringSlice::default() }); }; } } } fn append_doctype_to_document( &self, name: StrTendril, public_id: StrTendril, system_id: StrTendril, ) { let name_str = StringSlice{ ptr: name.as_ptr(), len: name.len()}; let public_id_str = StringSlice{ ptr: public_id.as_ptr(), len: public_id.len()}; let system_id_str = StringSlice{ ptr: system_id.as_ptr(), len: system_id.len()}; unsafe { (self.append_doctype_to_document)(self.ctx, name_str, public_id_str, system_id_str); } } fn add_attrs_if_missing(&self, target: &Ref, attrs: Vec<Attribute>) { unsafe { let mut attribute_iterator = CAttributeIterator { vec: attrs, pos: 0 }; (self.add_attrs_if_missing_callback)( self.ctx, *target, &mut attribute_iterator as *mut _ as *mut c_void, ); } } fn remove_from_parent(&self, target: &Ref) { unsafe { (self.remove_from_parent_callback)(self.ctx, *target); } } fn reparent_children(&self, node: &Ref, new_parent: &Ref) { unsafe { (self.reparent_children_callback)(self.ctx, *node, *new_parent); } } } ================================================ FILE: src/html5ever/types.rs ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. use std::ptr; use html5ever::{QualName, Attribute}; use std::os::raw::{c_uchar, c_void}; pub type CreateElementCallback = unsafe extern "C" fn( ctx: Ref, data: *const c_void, name: CQualName, attributes: *mut c_void, ) -> Ref; pub type CreateCommentCallback = unsafe extern "C" fn( ctx: Ref, str: StringSlice, ) -> Ref; pub type AppendDoctypeToDocumentCallback = unsafe extern "C" fn( ctx: Ref, name: StringSlice, public_id: StringSlice, system_id: StringSlice, ) -> (); pub type CreateProcessingInstruction = unsafe extern "C" fn( ctx: Ref, target: StringSlice, data: StringSlice, ) -> Ref; pub type GetDataCallback = unsafe extern "C" fn(ctx: Ref) -> *mut c_void; pub type AppendCallback = unsafe extern "C" fn( ctx: Ref, parent: Ref, node_or_text: CNodeOrText ) -> (); pub type ParseErrorCallback = unsafe extern "C" fn(ctx: Ref, str: StringSlice) -> (); pub type PopCallback = unsafe extern "C" fn(ctx: Ref, node: Ref) -> (); pub type AddAttrsIfMissingCallback = unsafe extern "C" fn( ctx: Ref, target: Ref, attributes: *mut c_void, ) -> (); pub type GetTemplateContentsCallback = unsafe extern "C" fn(ctx: Ref, target: Ref) -> Ref; pub type RemoveFromParentCallback = unsafe extern "C" fn(ctx: Ref, target: Ref) -> (); pub type ReparentChildrenCallback = unsafe extern "C" fn(ctx: Ref, node: Ref, new_parent: Ref) -> (); pub type AppendBeforeSiblingCallback = unsafe extern "C" fn( ctx: Ref, sibling: Ref, node_or_text: CNodeOrText ) -> (); pub type AppendBasedOnParentNodeCallback = unsafe extern "C" fn( ctx: Ref, element: Ref, prev_element: Ref, node_or_text: CNodeOrText ) -> (); pub type Ref = *const c_void; #[repr(C)] pub struct CNullable<T> { tag: u8, // 0 = None, 1 = Some value: T, } impl<T: Default> CNullable<T> { pub fn none() -> CNullable<T> { return Self{tag: 0, value: T::default()}; } pub fn some(v: T) -> CNullable<T> { return Self{tag: 1, value: v}; } } #[repr(C)] pub struct Slice<T> { pub ptr: *const T, pub len: usize, } impl<T> Default for Slice<T> { fn default() -> Self { return Self{ptr: ptr::null(), len: 0}; } } pub type StringSlice = Slice<c_uchar>; #[repr(C)] pub struct CQualName { prefix: CNullable<StringSlice>, ns: StringSlice, local: StringSlice, } impl CQualName { pub fn create(q: &QualName) -> Self { let ns = StringSlice { ptr: q.ns.as_ptr(), len: q.ns.len()}; let local = StringSlice { ptr: q.local.as_ptr(), len: q.local.len()}; let prefix = match &q.prefix { None => CNullable::<StringSlice>::none(), Some(prefix) => CNullable::<StringSlice>::some(StringSlice { ptr: prefix.as_ptr(), len: prefix.len()}), }; return CQualName{ // inner: q as *const _ as *const c_void, ns: ns, local: local, prefix: prefix, }; } } impl Default for CQualName { fn default() -> Self { return Self{ prefix: CNullable::<StringSlice>::none(), ns: StringSlice::default(), local: StringSlice::default(), }; } } #[repr(C)] pub struct CAttribute { pub name: CQualName, pub value: StringSlice, } impl Default for CAttribute { fn default() -> Self { return Self{name: CQualName::default(), value: StringSlice::default()}; } } pub struct CAttributeIterator { pub vec: Vec<Attribute>, pub pos: usize, } #[repr(C)] pub struct CNodeOrText { pub tag: u8, // 0 = node, 1 = text pub node: Ref, pub text: StringSlice, } ================================================ FILE: src/id.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); pub fn uuidv4(hex: []u8) void { lp.assert(hex.len == 36, "uuidv4.len", .{ .len = hex.len }); var bin: [16]u8 = undefined; std.crypto.random.bytes(&bin); bin[6] = (bin[6] & 0x0f) | 0x40; bin[8] = (bin[8] & 0x3f) | 0x80; const alphabet = "0123456789abcdef"; hex[8] = '-'; hex[13] = '-'; hex[18] = '-'; hex[23] = '-'; const encoded_pos = [16]u8{ 0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34 }; inline for (encoded_pos, 0..) |i, j| { hex[i + 0] = alphabet[bin[j] >> 4]; hex[i + 1] = alphabet[bin[j] & 0x0f]; } } const testing = std.testing; test "id: uuiv4" { const expectUUID = struct { fn expect(uuid: [36]u8) !void { for (uuid, 0..) |b, i| { switch (b) { '0'...'9', 'a'...'z' => {}, '-' => { if (i != 8 and i != 13 and i != 18 and i != 23) { return error.InvalidEncoding; } }, else => return error.InvalidHexEncoding, } } } }.expect; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var seen = std.StringHashMapUnmanaged(void){}; for (0..100) |_| { var hex: [36]u8 = undefined; uuidv4(&hex); try expectUUID(hex); try seen.put(allocator, try allocator.dupe(u8, &hex), {}); } try testing.expectEqual(100, seen.count()); } ================================================ FILE: src/lightpanda.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); pub const App = @import("App.zig"); pub const Network = @import("network/Runtime.zig"); pub const Server = @import("Server.zig"); pub const Config = @import("Config.zig"); pub const URL = @import("browser/URL.zig"); pub const String = @import("string.zig").String; pub const Page = @import("browser/Page.zig"); pub const Browser = @import("browser/Browser.zig"); pub const Session = @import("browser/Session.zig"); pub const Notification = @import("Notification.zig"); pub const log = @import("log.zig"); pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); pub const markdown = @import("browser/markdown.zig"); pub const SemanticTree = @import("SemanticTree.zig"); pub const CDPNode = @import("cdp/Node.zig"); pub const interactive = @import("browser/interactive.zig"); pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); pub const HttpClient = @import("browser/HttpClient.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { wait_ms: u32 = 5000, dump: dump.Opts, dump_mode: ?Config.DumpFormat = null, writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { const http_client = try HttpClient.init(app.allocator, &app.network); defer http_client.deinit(); const notification = try Notification.init(app.allocator); defer notification.deinit(); var browser = try Browser.init(app, .{ .http_client = http_client }); defer browser.deinit(); var session = try browser.newSession(notification); const page = try session.createPage(); // // Comment this out to get a profile of the JS code in v8/profile.json. // // You can open this in Chrome's profiler. // // I've seen it generate invalid JSON, but I'm not sure why. It // // happens rarely, and I manually fix the file. // page.js.startCpuProfiler(); // defer { // if (page.js.stopCpuProfiler()) |profile| { // std.fs.cwd().writeFile(.{ // .sub_path = ".lp-cache/cpu_profile.json", // .data = profile, // }) catch |err| { // log.err(.app, "profile write error", .{ .err = err }); // }; // } else |err| { // log.err(.app, "profile error", .{ .err = err }); // } // } // // Comment this out to get a heap V8 heap profil // page.js.startHeapProfiler(); // defer { // if (page.js.stopHeapProfiler()) |profile| { // std.fs.cwd().writeFile(.{ // .sub_path = ".lp-cache/allocating.heapprofile", // .data = profile.@"0", // }) catch |err| { // log.err(.app, "allocating write error", .{ .err = err }); // }; // std.fs.cwd().writeFile(.{ // .sub_path = ".lp-cache/snapshot.heapsnapshot", // .data = profile.@"1", // }) catch |err| { // log.err(.app, "heapsnapshot write error", .{ .err = err }); // }; // } else |err| { // log.err(.app, "profile error", .{ .err = err }); // } // } const encoded_url = try URL.ensureEncoded(page.call_arena, url); _ = try page.navigate(encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null }, }); _ = session.wait(opts.wait_ms); const writer = opts.writer orelse return; if (opts.dump_mode) |mode| { switch (mode) { .html => try dump.root(page.window._document, opts.dump, writer, page), .markdown => try markdown.dump(page.window._document.asNode(), .{}, writer, page), .semantic_tree, .semantic_tree_text => { var registry = CDPNode.Registry.init(app.allocator); defer registry.deinit(); const st: SemanticTree = .{ .dom_node = page.window._document.asNode(), .registry = ®istry, .page = page, .arena = page.call_arena, .prune = (mode == .semantic_tree_text), }; if (mode == .semantic_tree) { try std.json.Stringify.value(st, .{}, writer); } else { try st.textStringify(writer); } }, .wpt => try dumpWPT(page, writer), } } try writer.flush(); } fn dumpWPT(page: *Page, writer: *std.Io.Writer) !void { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var try_catch: js.TryCatch = undefined; try_catch.init(&ls.local); defer try_catch.deinit(); // return the detailed result. const dump_script = \\ JSON.stringify((() => { \\ const statuses = ['Pass', 'Fail', 'Timeout', 'Not Run', 'Optional Feature Unsupported']; \\ const parse = (raw) => { \\ for (const status of statuses) { \\ const idx = raw.indexOf('|' + status); \\ if (idx !== -1) { \\ const name = raw.slice(0, idx); \\ const rest = raw.slice(idx + status.length + 1); \\ const message = rest.length > 0 && rest[0] === '|' ? rest.slice(1) : null; \\ return { name, status, message }; \\ } \\ } \\ return { name: raw, status: 'Unknown', message: null }; \\ }; \\ const cases = Object.values(report.cases).map(parse); \\ return { \\ url: window.location.href, \\ status: report.status, \\ message: report.message, \\ summary: { \\ total: cases.length, \\ passed: cases.filter(c => c.status === 'Pass').length, \\ failed: cases.filter(c => c.status === 'Fail').length, \\ timeout: cases.filter(c => c.status === 'Timeout').length, \\ notrun: cases.filter(c => c.status === 'Not Run').length, \\ unsupported: cases.filter(c => c.status === 'Optional Feature Unsupported').length \\ }, \\ cases \\ }; \\ })(), null, 2) ; const value = ls.local.exec(dump_script, "dump_script") catch |err| { const caught = try_catch.caughtOrError(page.call_arena, err); return writer.print("Caught error trying to access WPT's report: {f}\n", .{caught}); }; try writer.writeAll("== WPT Results==\n"); try writer.writeAll(try value.toStringSliceWithAlloc(page.call_arena)); } pub inline fn assert(ok: bool, comptime ctx: []const u8, args: anytype) void { if (!ok) { if (comptime IS_DEBUG) { unreachable; } assertionFailure(ctx, args); } } noinline fn assertionFailure(comptime ctx: []const u8, args: anytype) noreturn { @branchHint(.cold); if (@inComptime()) { @compileError(std.fmt.comptimePrint("assertion failure: " ++ ctx, args)); } @import("crash_handler.zig").crash(ctx, args, @returnAddress()); } test { std.testing.refAllDecls(@This()); } ================================================ FILE: src/log.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); const Thread = std.Thread; const is_debug = builtin.mode == .Debug; pub const Scope = enum { app, dom, bug, browser, cdp, console, http, page, js, event, scheduler, not_implemented, telemetry, unknown_prop, mcp, }; const Opts = struct { format: Format = if (is_debug) .pretty else .logfmt, level: Level = if (is_debug) .info else .warn, filter_scopes: []const Scope = &.{}, }; pub var opts = Opts{}; // synchronizes writes to the output var out_lock: Thread.Mutex = .{}; // synchronizes access to last_log var last_log_lock: Thread.Mutex = .{}; pub fn enabled(comptime scope: Scope, level: Level) bool { if (@intFromEnum(level) < @intFromEnum(opts.level)) { return false; } if (comptime builtin.mode == .Debug) { for (opts.filter_scopes) |fs| { if (fs == scope) { return false; } } } return true; } // Ugliness to support complex debug parameters. Could add better support for // this directly in writeValue, but we [currently] only need this in one place // and I kind of don't want to encourage / make this easy. pub fn separator() []const u8 { return if (opts.format == .pretty) "\n " else "; "; } pub const Level = enum { debug, info, warn, err, fatal, }; pub const Format = enum { logfmt, pretty, }; pub fn debug(comptime scope: Scope, comptime msg: []const u8, data: anytype) void { log(scope, .debug, msg, data); } pub fn info(comptime scope: Scope, comptime msg: []const u8, data: anytype) void { log(scope, .info, msg, data); } pub fn warn(comptime scope: Scope, comptime msg: []const u8, data: anytype) void { log(scope, .warn, msg, data); } pub fn err(comptime scope: Scope, comptime msg: []const u8, data: anytype) void { log(scope, .err, msg, data); } pub fn fatal(comptime scope: Scope, comptime msg: []const u8, data: anytype) void { log(scope, .fatal, msg, data); } pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype) void { if (enabled(scope, level) == false) { return; } std.debug.lockStdErr(); defer std.debug.unlockStdErr(); var buf: [4096]u8 = undefined; var stderr = std.fs.File.stderr(); var writer = stderr.writer(&buf); logTo(scope, level, msg, data, &writer.interface) catch |log_err| { std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"\n", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg }); }; } fn logTo(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, out: *std.Io.Writer) !void { comptime { if (msg.len > 30) { @compileError("log msg cannot be more than 30 characters: '" ++ msg ++ "'"); } for (msg) |b| { switch (b) { 'A'...'Z', 'a'...'z', ' ', '0'...'9', '_', '-', '.', '{', '}' => {}, else => @compileError("log msg contains an invalid character '" ++ msg ++ "'"), } } } switch (opts.format) { .logfmt => try logLogfmt(scope, level, msg, data, out), .pretty => try logPretty(scope, level, msg, data, out), } out.flush() catch return; } fn logLogfmt(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: *std.Io.Writer) !void { try logLogFmtPrefix(scope, level, msg, writer); inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| { const value = @field(data, f.name); if (std.meta.hasMethod(@TypeOf(value), "logFmt")) { try value.logFmt(f.name, LogFormatWriter{ .writer = writer }); } else { const key = " " ++ f.name ++ "="; try writer.writeAll(key); try writeValue(.logfmt, value, writer); } } try writer.writeByte('\n'); } fn logLogFmtPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: *std.Io.Writer) !void { try writer.writeAll("$time="); try writer.print("{d}", .{timestamp(.clock)}); try writer.writeAll(" $scope="); try writer.writeAll(@tagName(scope)); try writer.writeAll(" $level="); try writer.writeAll(if (level == .err) "error" else @tagName(level)); const full_msg = comptime blk: { // only wrap msg in quotes if it contains a space const prefix = " $msg="; if (std.mem.indexOfScalar(u8, msg, ' ') == null) { break :blk prefix ++ msg; } break :blk prefix ++ "\"" ++ msg ++ "\""; }; try writer.writeAll(full_msg); } fn logPretty(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: *std.Io.Writer) !void { try logPrettyPrefix(scope, level, msg, writer); inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| { const key = " " ++ f.name ++ " = "; try writer.writeAll(key); try writeValue(.pretty, @field(data, f.name), writer); try writer.writeByte('\n'); } try writer.writeByte('\n'); } fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: *std.Io.Writer) !void { if (scope == .console and level == .fatal and comptime std.mem.eql(u8, msg, "lightpanda")) { try writer.writeAll("\x1b[0;104mWARN "); } else { try writer.writeAll(switch (level) { .debug => "\x1b[0;36mDEBUG\x1b[0m ", .info => "\x1b[0;32mINFO\x1b[0m ", .warn => "\x1b[0;33mWARN\x1b[0m ", .err => "\x1b[0;31mERROR ", .fatal => "\x1b[0;35mFATAL ", }); } const prefix = @tagName(scope) ++ " : " ++ msg; try writer.writeAll(prefix); { // msg.len cannot be > 30, and @tagName(scope).len cannot be > 15 // so this is safe const padding = 55 - prefix.len; for (0..padding / 2) |_| { try writer.writeAll(" ."); } if (@mod(padding, 2) == 1) { try writer.writeByte(' '); } const el = elapsed(); try writer.print(" \x1b[0m[+{d}{s}]", .{ el.time, el.unit }); try writer.writeByte('\n'); } } pub fn writeValue(comptime format: Format, value: anytype, writer: *std.Io.Writer) !void { const T = @TypeOf(value); if (std.meta.hasMethod(T, "format")) { return writer.print("{f}", .{value}); } switch (@typeInfo(T)) { .optional => { if (value) |v| { return writeValue(format, v, writer); } return writer.writeAll("null"); }, .comptime_int, .int, .comptime_float, .float => { return writer.print("{d}", .{value}); }, .bool => { return writer.writeAll(if (value) "true" else "false"); }, .error_set => return writer.writeAll(@errorName(value)), .@"enum" => return writer.writeAll(@tagName(value)), .array => return writeValue(format, &value, writer), .pointer => |ptr| switch (ptr.size) { .slice => switch (ptr.child) { u8 => return writeString(format, value, writer), else => {}, }, .one => switch (@typeInfo(ptr.child)) { .array => |arr| if (arr.child == u8) { return writeString(format, value, writer); }, else => return writer.print("{f}", .{value}), }, else => {}, }, .@"union" => return writer.print("{}", .{value}), .@"struct" => return writer.print("{}", .{value}), else => {}, } @compileError("cannot log a: " ++ @typeName(T)); } fn writeString(comptime format: Format, value: []const u8, writer: *std.Io.Writer) !void { if (format == .pretty) { return writer.writeAll(value); } var space_count: usize = 0; var escape_count: usize = 0; var binary_count: usize = 0; for (value) |b| { switch (b) { '\r', '\n', '"' => escape_count += 1, ' ' => space_count += 1, '\t', '!', '#'...'~' => {}, // printable characters else => binary_count += 1, } } if (binary_count > 0) { // TODO: use a different encoding if the ratio of binary data / printable is low return std.base64.standard_no_pad.Encoder.encodeWriter(writer, value); } if (escape_count == 0) { if (space_count == 0) { return writer.writeAll(value); } try writer.writeByte('"'); try writer.writeAll(value); try writer.writeByte('"'); return; } try writer.writeByte('"'); var rest = value; while (rest.len > 0) { const pos = std.mem.indexOfAny(u8, rest, "\r\n\"") orelse { try writer.writeAll(rest); break; }; try writer.writeAll(rest[0..pos]); try writer.writeByte('\\'); switch (rest[pos]) { '"' => try writer.writeByte('"'), '\r' => try writer.writeByte('r'), '\n' => try writer.writeByte('n'), else => unreachable, } rest = rest[pos + 1 ..]; } return writer.writeByte('"'); } pub const LogFormatWriter = struct { writer: *std.Io.Writer, pub fn write(self: LogFormatWriter, key: []const u8, value: anytype) !void { const writer = self.writer; try writer.print(" {s}=", .{key}); try writeValue(.logfmt, value, writer); } }; var first_log: u64 = 0; fn elapsed() struct { time: f64, unit: []const u8 } { const now = timestamp(.monotonic); last_log_lock.lock(); defer last_log_lock.unlock(); if (first_log == 0) { first_log = now; } const e = now - first_log; if (e < 10_000) { return .{ .time = @floatFromInt(e), .unit = "ms" }; } return .{ .time = @as(f64, @floatFromInt(e)) / @as(f64, 1000), .unit = "s" }; } const datetime = @import("datetime.zig"); fn timestamp(comptime mode: datetime.TimestampMode) u64 { if (comptime @import("builtin").is_test) { return 1739795092929; } return datetime.milliTimestamp(mode); } const testing = @import("testing.zig"); test "log: data" { opts.format = .logfmt; defer opts.format = .pretty; var aw = std.Io.Writer.Allocating.init(testing.allocator); defer aw.deinit(); { try logTo(.browser, .err, "nope", .{}, &aw.writer); try testing.expectEqual("$time=1739795092929 $scope=browser $level=error $msg=nope\n", aw.written()); } { aw.clearRetainingCapacity(); const string = try testing.allocator.dupe(u8, "spice_must_flow"); defer testing.allocator.free(string); try logTo(.page, .warn, "a msg", .{ .cint = 5, .cfloat = 3.43, .int = @as(i16, -49), .float = @as(f32, 0.0003232), .bt = true, .bf = false, .nn = @as(?i32, 33), .n = @as(?i32, null), .lit = "over9000!", .slice = string, .err = error.Nope, .level = Level.warn, }, &aw.writer); try testing.expectEqual("$time=1739795092929 $scope=page $level=warn $msg=\"a msg\" " ++ "cint=5 cfloat=3.43 int=-49 float=0.0003232 bt=true bf=false " ++ "nn=33 n=null lit=over9000! slice=spice_must_flow " ++ "err=Nope level=warn\n", aw.written()); } } test "log: string escape" { opts.format = .logfmt; defer opts.format = .pretty; var aw = std.Io.Writer.Allocating.init(testing.allocator); defer aw.deinit(); const prefix = "$time=1739795092929 $scope=app $level=error $msg=test "; { try logTo(.app, .err, "test", .{ .string = "hello world" }, &aw.writer); try testing.expectEqual(prefix ++ "string=\"hello world\"\n", aw.written()); } { aw.clearRetainingCapacity(); try logTo(.app, .err, "test", .{ .string = "\n \thi \" \" " }, &aw.writer); try testing.expectEqual(prefix ++ "string=\"\\n \thi \\\" \\\" \"\n", aw.written()); } } ================================================ FILE: src/main.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const log = lp.log; const App = lp.App; const Config = lp.Config; const SigHandler = @import("Sighandler.zig"); pub const panic = lp.crash_handler.panic; pub fn main() !void { // allocator // - in Debug mode we use the General Purpose Allocator to detect memory leaks // - in Release mode we use the c allocator var gpa_instance: std.heap.DebugAllocator(.{ .stack_trace_frames = 10 }) = .init; const gpa = if (builtin.mode == .Debug) gpa_instance.allocator() else std.heap.c_allocator; defer if (builtin.mode == .Debug) { if (gpa_instance.detectLeaks()) std.posix.exit(1); }; // arena for main-specific allocations var main_arena_instance = std.heap.ArenaAllocator.init(gpa); const main_arena = main_arena_instance.allocator(); defer main_arena_instance.deinit(); run(gpa, main_arena) catch |err| { log.fatal(.app, "exit", .{ .err = err }); std.posix.exit(1); }; } fn run(allocator: Allocator, main_arena: Allocator) !void { const args = try Config.parseArgs(main_arena); defer args.deinit(main_arena); switch (args.mode) { .help => { args.printUsageAndExit(args.mode.help); return std.process.cleanExit(); }, .version => { if (lp.build_config.git_version) |version| { std.debug.print("{s} ({s})\n", .{ version, lp.build_config.git_commit }); } else { std.debug.print("{s}\n", .{lp.build_config.git_commit}); } return std.process.cleanExit(); }, else => {}, } if (args.logLevel()) |ll| { log.opts.level = ll; } if (args.logFormat()) |lf| { log.opts.format = lf; } if (args.logFilterScopes()) |lfs| { log.opts.filter_scopes = lfs; } // must be installed before any other threads const sighandler = try main_arena.create(SigHandler); sighandler.* = .{ .arena = main_arena }; try sighandler.install(); // _app is global to handle graceful shutdown. var app = try App.init(allocator, &args); defer app.deinit(); try sighandler.on(lp.Network.stop, .{&app.network}); app.telemetry.record(.{ .run = {} }); switch (args.mode) { .serve => |opts| { log.debug(.app, "startup", .{ .mode = "serve", .snapshot = app.snapshot.fromEmbedded() }); const address = std.net.Address.parseIp(opts.host, opts.port) catch |err| { log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port }); return args.printUsageAndExit(false); }; var server = lp.Server.init(app, address) catch |err| { if (err == error.AddressInUse) { log.fatal(.app, "address already in use", .{ .host = opts.host, .port = opts.port, .hint = "Another process is already listening on this address. " ++ "Stop the other process or use --port to choose a different port.", }); } else { log.fatal(.app, "server run error", .{ .err = err }); } return err; }; defer server.deinit(); try sighandler.on(lp.Server.shutdown, .{server}); app.network.run(); }, .fetch => |opts| { const url = opts.url; log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump_mode, .url = url, .snapshot = app.snapshot.fromEmbedded() }); var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, .dump_mode = opts.dump_mode, .dump = .{ .strip = opts.strip, .with_base = opts.with_base, .with_frames = opts.with_frames, }, }; var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); if (opts.dump_mode != null) { fetch_opts.writer = &writer.interface; } var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url, fetch_opts }); defer worker_thread.join(); app.network.run(); }, .mcp => { log.info(.mcp, "starting server", .{}); log.opts.format = .logfmt; var stdout = std.fs.File.stdout().writer(&.{}); var mcp_server: *lp.mcp.Server = try .init(allocator, app, &stdout.interface); defer mcp_server.deinit(); var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ mcp_server, app }); defer worker_thread.join(); app.network.run(); }, else => unreachable, } } fn fetchThread(app: *App, url: [:0]const u8, fetch_opts: lp.FetchOpts) void { defer app.network.stop(); lp.fetch(app, url, fetch_opts) catch |err| { log.fatal(.app, "fetch error", .{ .err = err, .url = url }); }; } fn mcpThread(mcp_server: *lp.mcp.Server, app: *App) void { defer app.network.stop(); var stdin_buf: [64 * 1024]u8 = undefined; var stdin = std.fs.File.stdin().reader(&stdin_buf); lp.mcp.router.processRequests(mcp_server, &stdin.interface) catch |err| { log.fatal(.mcp, "mcp error", .{ .err = err }); }; } ================================================ FILE: src/main_legacy_test.zig ================================================ const std = @import("std"); const lp = @import("lightpanda"); const Allocator = std.mem.Allocator; // used in custom panic handler var current_test: ?[]const u8 = null; pub fn main() !void { var gpa: std.heap.DebugAllocator(.{}) = .init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); var args = try std.process.argsWithAllocator(allocator); defer args.deinit(); _ = args.next(); // executable name var filter: ?[]const u8 = null; if (args.next()) |n| { filter = n; } var http_server = try TestHTTPServer.init(); defer http_server.deinit(); { var wg: std.Thread.WaitGroup = .{}; wg.startMany(1); var thrd = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &http_server, &wg }); thrd.detach(); wg.wait(); } lp.log.opts.level = .warn; const config = try lp.Config.init(allocator, "legacy-test", .{ .serve = .{ .common = .{ .tls_verify_host = false, .user_agent_suffix = "internal-tester", }, } }); defer config.deinit(allocator); var app = try lp.App.init(allocator, &config); defer app.deinit(); var test_arena = std.heap.ArenaAllocator.init(allocator); defer test_arena.deinit(); const http_client = try lp.HttpClient.init(allocator, &app.network); defer http_client.deinit(); var browser = try lp.Browser.init(app, .{ .http_client = http_client }); defer browser.deinit(); const notification = try lp.Notification.init(allocator); defer notification.deinit(); const session = try browser.newSession(notification); defer session.deinit(); var dir = try std.fs.cwd().openDir("src/browser/tests/legacy/", .{ .iterate = true, .no_follow = true }); defer dir.close(); var walker = try dir.walk(allocator); defer walker.deinit(); while (try walker.next()) |entry| { _ = test_arena.reset(.retain_capacity); if (entry.kind != .file) { continue; } if (!std.mem.endsWith(u8, entry.basename, ".html")) { continue; } if (std.mem.endsWith(u8, entry.basename, ".skip.html")) { continue; } if (filter) |f| { if (std.mem.indexOf(u8, entry.path, f) == null) { continue; } } std.debug.print("\n===={s}====\n", .{entry.path}); current_test = entry.path; run(test_arena.allocator(), entry.path, session) catch |err| { std.debug.print("Failure: {s} - {any}\n", .{ entry.path, err }); }; } } pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void { const url = try std.fmt.allocPrintSentinel(allocator, "http://localhost:9589/{s}", .{file}, 0); const page = try session.createPage(); defer session.removePage(); var ls: lp.js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var try_catch: lp.js.TryCatch = undefined; try_catch.init(&ls.local); defer try_catch.deinit(); try page.navigate(url, .{}); _ = session.wait(2000); ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| { const caught = try_catch.caughtOrError(allocator, err); std.debug.print("{s}: test failure\nError: {f}\n", .{ file, caught }); return err; }; } const TestHTTPServer = struct { shutdown: bool, dir: std.fs.Dir, listener: ?std.net.Server, pub fn init() !TestHTTPServer { return .{ .dir = try std.fs.cwd().openDir("src/browser/tests/legacy/", .{}), .shutdown = true, .listener = null, }; } pub fn deinit(self: *TestHTTPServer) void { self.shutdown = true; if (self.listener) |*listener| { listener.deinit(); } self.dir.close(); } pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void { const address = try std.net.Address.parseIp("127.0.0.1", 9589); self.listener = try address.listen(.{ .reuse_address = true }); var listener = &self.listener.?; wg.finish(); while (true) { const conn = listener.accept() catch |err| { if (self.shutdown) { return; } return err; }; const thrd = try std.Thread.spawn(.{}, handleConnection, .{ self, conn }); thrd.detach(); } } fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !void { defer conn.stream.close(); var req_buf: [2048]u8 = undefined; var conn_reader = conn.stream.reader(&req_buf); var conn_writer = conn.stream.writer(&req_buf); var http_server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface); while (true) { var req = http_server.receiveHead() catch |err| switch (err) { error.ReadFailed => continue, error.HttpConnectionClosing => continue, else => { std.debug.print("Test HTTP Server error: {}\n", .{err}); return err; }, }; self.handler(&req) catch |err| { std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err }); try req.respond("server error", .{ .status = .internal_server_error }); return; }; } } fn handler(server: *TestHTTPServer, req: *std.http.Server.Request) !void { const path = req.head.target; if (std.mem.eql(u8, path, "/xhr")) { return req.respond("1234567890" ** 10, .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, }, }); } if (std.mem.eql(u8, path, "/xhr/json")) { return req.respond("{\"over\":\"9000!!!\"}", .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "application/json" }, }, }); } // strip out leading '/' to make the path relative const file = try server.dir.openFile(path[1..], .{}); defer file.close(); const stat = try file.stat(); var send_buffer: [4096]u8 = undefined; var res = try req.respondStreaming(&send_buffer, .{ .content_length = stat.size, .respond_options = .{ .extra_headers = &.{ .{ .name = "content-type", .value = getContentType(path) }, }, }, }); var read_buffer: [4096]u8 = undefined; var reader = file.reader(&read_buffer); _ = try res.writer.sendFileAll(&reader, .unlimited); try res.writer.flush(); try res.end(); } pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void { var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) { error.FileNotFound => return req.respond("server error", .{ .status = .not_found }), else => return err, }; defer file.close(); const stat = try file.stat(); var send_buffer: [4096]u8 = undefined; var res = try req.respondStreaming(&send_buffer, .{ .content_length = stat.size, .respond_options = .{ .extra_headers = &.{ .{ .name = "content-type", .value = getContentType(file_path) }, }, }, }); var read_buffer: [4096]u8 = undefined; var reader = file.reader(&read_buffer); _ = try res.writer.sendFileAll(&reader, .unlimited); try res.writer.flush(); try res.end(); } fn getContentType(file_path: []const u8) []const u8 { if (std.mem.endsWith(u8, file_path, ".js")) { return "application/json"; } if (std.mem.endsWith(u8, file_path, ".html")) { return "text/html"; } if (std.mem.endsWith(u8, file_path, ".htm")) { return "text/html"; } if (std.mem.endsWith(u8, file_path, ".xml")) { // some wpt tests do this return "text/xml"; } std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path}); return "text/html"; } }; pub const panic = std.debug.FullPanic(struct { pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn { if (current_test) |ct| { std.debug.print("===panic running: {s}===\n", .{ct}); } std.debug.defaultPanic(msg, first_trace_addr); } }.panicFn); ================================================ FILE: src/main_snapshot_creator.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const lp = @import("lightpanda"); pub fn main() !void { const allocator = std.heap.c_allocator; var platform = try lp.js.Platform.init(); defer platform.deinit(); const snapshot = try lp.js.Snapshot.create(); defer snapshot.deinit(); var is_stdout = true; var file = std.fs.File.stdout(); var args = try std.process.argsWithAllocator(allocator); _ = args.next(); // executable name if (args.next()) |n| { is_stdout = false; file = try std.fs.cwd().createFile(n, .{}); } defer if (!is_stdout) { file.close(); }; var buffer: [4096]u8 = undefined; var writer = file.writer(&buffer); try snapshot.write(&writer.interface); try writer.end(); } ================================================ FILE: src/mcp/Server.zig ================================================ const std = @import("std"); const lp = @import("lightpanda"); const App = @import("../App.zig"); const HttpClient = @import("../browser/HttpClient.zig"); const testing = @import("../testing.zig"); const protocol = @import("protocol.zig"); const router = @import("router.zig"); const CDPNode = @import("../cdp/Node.zig"); const Self = @This(); allocator: std.mem.Allocator, app: *App, http_client: *HttpClient, notification: *lp.Notification, browser: lp.Browser, session: *lp.Session, node_registry: CDPNode.Registry, writer: *std.io.Writer, mutex: std.Thread.Mutex = .{}, aw: std.io.Writer.Allocating, pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self { const http_client = try HttpClient.init(allocator, &app.network); errdefer http_client.deinit(); const notification = try lp.Notification.init(allocator); errdefer notification.deinit(); const self = try allocator.create(Self); errdefer allocator.destroy(self); var browser = try lp.Browser.init(app, .{ .http_client = http_client }); errdefer browser.deinit(); self.* = .{ .allocator = allocator, .app = app, .writer = writer, .browser = browser, .aw = .init(allocator), .http_client = http_client, .notification = notification, .session = undefined, .node_registry = CDPNode.Registry.init(allocator), }; self.session = try self.browser.newSession(self.notification); return self; } pub fn deinit(self: *Self) void { self.node_registry.deinit(); self.aw.deinit(); self.browser.deinit(); self.notification.deinit(); self.http_client.deinit(); self.allocator.destroy(self); } pub fn sendResponse(self: *Self, response: anytype) !void { self.mutex.lock(); defer self.mutex.unlock(); self.aw.clearRetainingCapacity(); try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &self.aw.writer); try self.aw.writer.writeByte('\n'); try self.writer.writeAll(self.aw.writer.buffered()); try self.writer.flush(); } pub fn sendResult(self: *Self, id: std.json.Value, result: anytype) !void { const GenericResponse = struct { jsonrpc: []const u8 = "2.0", id: std.json.Value, result: @TypeOf(result), }; try self.sendResponse(GenericResponse{ .id = id, .result = result, }); } pub fn sendError(self: *Self, id: std.json.Value, code: protocol.ErrorCode, message: []const u8) !void { try self.sendResponse(.{ .id = id, .@"error" = protocol.Error{ .code = @intFromEnum(code), .message = message, }, }); } test "MCP.Server - Integration: synchronous smoke test" { defer testing.reset(); const allocator = testing.allocator; const app = testing.test_app; const input = \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}} ; var in_reader: std.io.Reader = .fixed(input); var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); defer out_alloc.deinit(); var server = try Self.init(allocator, app, &out_alloc.writer); defer server.deinit(); try router.processRequests(server, &in_reader); try testing.expectJson(.{ .id = 1 }, out_alloc.writer.buffered()); } test "MCP.Server - Integration: ping request returns an empty result" { defer testing.reset(); const allocator = testing.allocator; const app = testing.test_app; const input = \\{"jsonrpc":"2.0","id":"ping-1","method":"ping"} ; var in_reader: std.io.Reader = .fixed(input); var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); defer out_alloc.deinit(); var server = try Self.init(allocator, app, &out_alloc.writer); defer server.deinit(); try router.processRequests(server, &in_reader); try testing.expectJson(.{ .id = "ping-1", .result = .{} }, out_alloc.writer.buffered()); } ================================================ FILE: src/mcp/protocol.zig ================================================ const std = @import("std"); pub const Request = struct { jsonrpc: []const u8 = "2.0", id: ?std.json.Value = null, method: []const u8, params: ?std.json.Value = null, }; pub const Response = struct { jsonrpc: []const u8 = "2.0", id: std.json.Value, result: ?std.json.Value = null, @"error": ?Error = null, }; pub const Error = struct { code: i64, message: []const u8, data: ?std.json.Value = null, }; pub const ErrorCode = enum(i64) { ParseError = -32700, InvalidRequest = -32600, MethodNotFound = -32601, InvalidParams = -32602, InternalError = -32603, PageNotLoaded = -32604, }; pub const Notification = struct { jsonrpc: []const u8 = "2.0", method: []const u8, params: ?std.json.Value = null, }; // Core MCP Types mapping to official specification pub const InitializeRequest = struct { jsonrpc: []const u8 = "2.0", id: std.json.Value, method: []const u8 = "initialize", params: InitializeParams, }; pub const InitializeParams = struct { protocolVersion: []const u8, capabilities: Capabilities, clientInfo: Implementation, }; pub const Capabilities = struct { experimental: ?std.json.Value = null, roots: ?RootsCapability = null, sampling: ?SamplingCapability = null, }; pub const RootsCapability = struct { listChanged: ?bool = null, }; pub const SamplingCapability = struct {}; pub const Implementation = struct { name: []const u8, version: []const u8, }; pub const InitializeResult = struct { protocolVersion: []const u8, capabilities: ServerCapabilities, serverInfo: Implementation, }; pub const ServerCapabilities = struct { experimental: ?std.json.Value = null, logging: ?LoggingCapability = null, prompts: ?PromptsCapability = null, resources: ?ResourcesCapability = null, tools: ?ToolsCapability = null, }; pub const LoggingCapability = struct {}; pub const PromptsCapability = struct { listChanged: ?bool = null, }; pub const ResourcesCapability = struct { subscribe: ?bool = null, listChanged: ?bool = null, }; pub const ToolsCapability = struct { listChanged: ?bool = null, }; pub const Tool = struct { name: []const u8, description: ?[]const u8 = null, inputSchema: []const u8, pub fn jsonStringify(self: @This(), jw: anytype) !void { try jw.beginObject(); try jw.objectField("name"); try jw.write(self.name); if (self.description) |d| { try jw.objectField("description"); try jw.write(d); } try jw.objectField("inputSchema"); _ = try jw.beginWriteRaw(); try jw.writer.writeAll(self.inputSchema); jw.endWriteRaw(); try jw.endObject(); } }; pub fn minify(comptime json: []const u8) []const u8 { @setEvalBranchQuota(100000); return comptime blk: { var res: []const u8 = ""; var in_string = false; var escaped = false; for (json) |c| { if (in_string) { res = res ++ [1]u8{c}; if (escaped) { escaped = false; } else if (c == '\\') { escaped = true; } else if (c == '"') { in_string = false; } } else { switch (c) { ' ', '\n', '\r', '\t' => continue, '"' => { in_string = true; res = res ++ [1]u8{c}; }, else => res = res ++ [1]u8{c}, } } } break :blk res; }; } pub const Resource = struct { uri: []const u8, name: []const u8, description: ?[]const u8 = null, mimeType: ?[]const u8 = null, }; pub fn TextContent(comptime T: type) type { return struct { type: []const u8 = "text", text: T, }; } pub fn CallToolResult(comptime T: type) type { return struct { content: []const TextContent(T), isError: bool = false, }; } pub const JsonEscapingWriter = struct { inner_writer: *std.Io.Writer, writer: std.Io.Writer, pub fn init(inner_writer: *std.Io.Writer) JsonEscapingWriter { return .{ .inner_writer = inner_writer, .writer = .{ .vtable = &vtable, .buffer = &.{}, }, }; } const vtable = std.Io.Writer.VTable{ .drain = drain, }; fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { const self: *JsonEscapingWriter = @alignCast(@fieldParentPtr("writer", w)); var total: usize = 0; for (data[0 .. data.len - 1]) |slice| { std.json.Stringify.encodeJsonStringChars(slice, .{}, self.inner_writer) catch return error.WriteFailed; total += slice.len; } const pattern = data[data.len - 1]; for (0..splat) |_| { std.json.Stringify.encodeJsonStringChars(pattern, .{}, self.inner_writer) catch return error.WriteFailed; total += pattern.len; } return total; } }; const testing = @import("../testing.zig"); test "MCP.protocol - request parsing" { defer testing.reset(); const raw_json = \\{ \\ "jsonrpc": "2.0", \\ "id": 1, \\ "method": "initialize", \\ "params": { \\ "protocolVersion": "2024-11-05", \\ "capabilities": {}, \\ "clientInfo": { \\ "name": "test-client", \\ "version": "1.0.0" \\ } \\ } \\} ; const parsed = try std.json.parseFromSlice(Request, testing.arena_allocator, raw_json, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); const req = parsed.value; try testing.expectString("2.0", req.jsonrpc); try testing.expectString("initialize", req.method); try testing.expect(req.id.? == .integer); try testing.expectEqual(@as(i64, 1), req.id.?.integer); try testing.expect(req.params != null); // Test nested parsing of InitializeParams const init_params = try std.json.parseFromValue(InitializeParams, testing.arena_allocator, req.params.?, .{ .ignore_unknown_fields = true }); defer init_params.deinit(); try testing.expectString("2024-11-05", init_params.value.protocolVersion); try testing.expectString("test-client", init_params.value.clientInfo.name); try testing.expectString("1.0.0", init_params.value.clientInfo.version); } test "MCP.protocol - ping request parsing" { defer testing.reset(); const raw_json = \\{ \\ "jsonrpc": "2.0", \\ "id": "123", \\ "method": "ping" \\} ; const parsed = try std.json.parseFromSlice(Request, testing.arena_allocator, raw_json, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); const req = parsed.value; try testing.expectString("2.0", req.jsonrpc); try testing.expectString("ping", req.method); try testing.expect(req.id.? == .string); try testing.expectString("123", req.id.?.string); try testing.expectEqual(null, req.params); } test "MCP.protocol - response formatting" { defer testing.reset(); const response = Response{ .id = .{ .integer = 42 }, .result = .{ .string = "success" }, }; var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator); defer aw.deinit(); try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &aw.writer); try testing.expectString("{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":\"success\"}", aw.written()); } test "MCP.protocol - error formatting" { defer testing.reset(); const response = Response{ .id = .{ .string = "abc" }, .@"error" = .{ .code = @intFromEnum(ErrorCode.MethodNotFound), .message = "Method not found", }, }; var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator); defer aw.deinit(); try std.json.Stringify.value(response, .{ .emit_null_optional_fields = false }, &aw.writer); try testing.expectString("{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}", aw.written()); } test "MCP.protocol - JsonEscapingWriter" { defer testing.reset(); var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator); defer aw.deinit(); var escaping_writer = JsonEscapingWriter.init(&aw.writer); // test newlines and quotes try escaping_writer.writer.writeAll("hello\n\"world\""); // the writer outputs escaped string chars without surrounding quotes try testing.expectString("hello\\n\\\"world\\\"", aw.written()); } test "MCP.protocol - Tool serialization" { defer testing.reset(); const t = Tool{ .name = "test", .inputSchema = minify( \\{ \\ "type": "object", \\ "properties": { \\ "foo": { "type": "string" } \\ } \\} ), }; var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator); defer aw.deinit(); try std.json.Stringify.value(t, .{}, &aw.writer); try testing.expectString("{\"name\":\"test\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"foo\":{\"type\":\"string\"}}}}", aw.written()); } ================================================ FILE: src/mcp/resources.zig ================================================ const std = @import("std"); const lp = @import("lightpanda"); const log = lp.log; const protocol = @import("protocol.zig"); const Server = @import("Server.zig"); pub const resource_list = [_]protocol.Resource{ .{ .uri = "mcp://page/html", .name = "Page HTML", .description = "The serialized HTML DOM of the current page", .mimeType = "text/html", }, .{ .uri = "mcp://page/markdown", .name = "Page Markdown", .description = "The token-efficient markdown representation of the current page", .mimeType = "text/markdown", }, }; pub fn handleList(server: *Server, req: protocol.Request) !void { try server.sendResult(req.id.?, .{ .resources = &resource_list }); } const ReadParams = struct { uri: []const u8, }; const ResourceStreamingResult = struct { contents: []const struct { uri: []const u8, mimeType: []const u8, text: StreamingText, }, const StreamingText = struct { page: *lp.Page, format: enum { html, markdown }, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { try jw.beginWriteRaw(); try jw.writer.writeByte('"'); var escaped = protocol.JsonEscapingWriter.init(jw.writer); switch (self.format) { .html => lp.dump.root(self.page.document, .{}, &escaped.writer, self.page) catch |err| { log.err(.mcp, "html dump failed", .{ .err = err }); }, .markdown => lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| { log.err(.mcp, "markdown dump failed", .{ .err = err }); }, } try jw.writer.writeByte('"'); jw.endWriteRaw(); } }; }; const ResourceUri = enum { @"mcp://page/html", @"mcp://page/markdown", }; const resource_map = std.StaticStringMap(ResourceUri).initComptime(.{ .{ "mcp://page/html", .@"mcp://page/html" }, .{ "mcp://page/markdown", .@"mcp://page/markdown" }, }); pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { if (req.params == null or req.id == null) { return server.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); } const req_id = req.id.?; const params = std.json.parseFromValueLeaky(ReadParams, arena, req.params.?, .{ .ignore_unknown_fields = true }) catch { return server.sendError(req_id, .InvalidParams, "Invalid params"); }; const uri = resource_map.get(params.uri) orelse { return server.sendError(req_id, .InvalidRequest, "Resource not found"); }; const page = server.session.currentPage() orelse { return server.sendError(req_id, .PageNotLoaded, "Page not loaded"); }; switch (uri) { .@"mcp://page/html" => { const result: ResourceStreamingResult = .{ .contents = &.{.{ .uri = params.uri, .mimeType = "text/html", .text = .{ .page = page, .format = .html }, }}, }; try server.sendResult(req_id, result); }, .@"mcp://page/markdown" => { const result: ResourceStreamingResult = .{ .contents = &.{.{ .uri = params.uri, .mimeType = "text/markdown", .text = .{ .page = page, .format = .markdown }, }}, }; try server.sendResult(req_id, result); }, } } const testing = @import("../testing.zig"); ================================================ FILE: src/mcp/router.zig ================================================ const std = @import("std"); const lp = @import("lightpanda"); const protocol = @import("protocol.zig"); const resources = @import("resources.zig"); const Server = @import("Server.zig"); const tools = @import("tools.zig"); pub fn processRequests(server: *Server, reader: *std.io.Reader) !void { var arena: std.heap.ArenaAllocator = .init(server.allocator); defer arena.deinit(); while (true) { _ = arena.reset(.retain_capacity); const aa = arena.allocator(); const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) { error.StreamTooLong => { log.err(.mcp, "Message too long", .{}); continue; }, else => return err, } orelse break; const trimmed = std.mem.trim(u8, buffered_line, " \r\t"); if (trimmed.len > 0) { handleMessage(server, aa, trimmed) catch |err| { log.err(.mcp, "Failed to handle message", .{ .err = err, .msg = trimmed }); }; } } } const log = @import("../log.zig"); const Method = enum { initialize, ping, @"notifications/initialized", @"tools/list", @"tools/call", @"resources/list", @"resources/read", }; const method_map = std.StaticStringMap(Method).initComptime(.{ .{ "initialize", .initialize }, .{ "ping", .ping }, .{ "notifications/initialized", .@"notifications/initialized" }, .{ "tools/list", .@"tools/list" }, .{ "tools/call", .@"tools/call" }, .{ "resources/list", .@"resources/list" }, .{ "resources/read", .@"resources/read" }, }); pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8) !void { const req = std.json.parseFromSliceLeaky(protocol.Request, arena, msg, .{ .ignore_unknown_fields = true, }) catch |err| { log.warn(.mcp, "JSON Parse Error", .{ .err = err, .msg = msg }); try server.sendError(.null, .ParseError, "Parse error"); return; }; const method = method_map.get(req.method) orelse { if (req.id != null) { try server.sendError(req.id.?, .MethodNotFound, "Method not found"); } return; }; switch (method) { .initialize => try handleInitialize(server, req), .ping => try handlePing(server, req), .@"notifications/initialized" => {}, .@"tools/list" => try tools.handleList(server, arena, req), .@"tools/call" => try tools.handleCall(server, arena, req), .@"resources/list" => try resources.handleList(server, req), .@"resources/read" => try resources.handleRead(server, arena, req), } } fn handleInitialize(server: *Server, req: protocol.Request) !void { const result = protocol.InitializeResult{ .protocolVersion = "2025-11-25", .capabilities = .{ .resources = .{}, .tools = .{}, }, .serverInfo = .{ .name = "lightpanda", .version = "0.1.0", }, }; try server.sendResult(req.id.?, result); } fn handlePing(server: *Server, req: protocol.Request) !void { const id = req.id orelse return; try server.sendResult(id, .{}); } const testing = @import("../testing.zig"); test "MCP.router - handleMessage - synchronous unit tests" { defer testing.reset(); const allocator = testing.allocator; const app = testing.test_app; var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); defer out_alloc.deinit(); var server = try Server.init(allocator, app, &out_alloc.writer); defer server.deinit(); const aa = testing.arena_allocator; // 1. Valid handshake try handleMessage(server, aa, \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}} ); try testing.expectJson( \\{ "id": 1, "result": { "capabilities": { "tools": {} } } } , out_alloc.writer.buffered()); out_alloc.writer.end = 0; // 2. Ping try handleMessage(server, aa, \\{"jsonrpc":"2.0","id":2,"method":"ping"} ); try testing.expectJson(.{ .id = 2, .result = .{} }, out_alloc.writer.buffered()); out_alloc.writer.end = 0; // 3. Tools list try handleMessage(server, aa, \\{"jsonrpc":"2.0","id":3,"method":"tools/list"} ); try testing.expectJson(.{ .id = 3 }, out_alloc.writer.buffered()); try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "\"name\":\"goto\"") != null); out_alloc.writer.end = 0; // 4. Method not found try handleMessage(server, aa, \\{"jsonrpc":"2.0","id":4,"method":"unknown_method"} ); try testing.expectJson(.{ .id = 4, .@"error" = .{ .code = -32601 } }, out_alloc.writer.buffered()); out_alloc.writer.end = 0; // 5. Parse error { const filter: testing.LogFilter = .init(&.{.mcp}); defer filter.deinit(); try handleMessage(server, aa, "invalid json"); try testing.expectJson("{\"id\": null, \"error\": {\"code\": -32700}}", out_alloc.writer.buffered()); } } ================================================ FILE: src/mcp/tools.zig ================================================ const std = @import("std"); const lp = @import("lightpanda"); const log = lp.log; const js = lp.js; const Element = @import("../browser/webapi/Element.zig"); const DOMNode = @import("../browser/webapi/Node.zig"); const Selector = @import("../browser/webapi/selector/Selector.zig"); const protocol = @import("protocol.zig"); const Server = @import("Server.zig"); const CDPNode = @import("../cdp/Node.zig"); pub const tool_list = [_]protocol.Tool{ .{ .name = "goto", .description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." } \\ }, \\ "required": ["url"] \\} ), }, .{ .name = "markdown", .description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching markdown." } \\ } \\} ), }, .{ .name = "links", .description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting links." } \\ } \\} ), }, .{ .name = "evaluate", .description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "script": { "type": "string" }, \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." } \\ }, \\ "required": ["script"] \\} ), }, .{ .name = "semantic_tree", .description = "Get the page content as a simplified semantic DOM tree for AI reasoning. If a url is provided, it navigates to that url first.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." }, \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." }, \\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." } \\ } \\} ), }, .{ .name = "interactiveElements", .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting interactive elements." } \\ } \\} ), }, .{ .name = "structuredData", .description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting structured data." } \\ } \\} ), }, .{ .name = "click", .description = "Click on an interactive element.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to click." } \\ }, \\ "required": ["backendNodeId"] \\} ), }, .{ .name = "fill", .description = "Fill text into an input element.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the input element to fill." }, \\ "text": { "type": "string", "description": "The text to fill into the input element." } \\ }, \\ "required": ["backendNodeId", "text"] \\} ), }, .{ .name = "scroll", .description = "Scroll the page or a specific element.", .inputSchema = protocol.minify( \\{ \\ "type": "object", \\ "properties": { \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." }, \\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." }, \\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." } \\ } \\} ), }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { _ = arena; try server.sendResult(req.id.?, .{ .tools = &tool_list }); } const GotoParams = struct { url: [:0]const u8, }; const EvaluateParams = struct { script: [:0]const u8, url: ?[:0]const u8 = null, }; const ToolStreamingText = struct { page: *lp.Page, action: enum { markdown, links, semantic_tree }, registry: ?*CDPNode.Registry = null, arena: ?std.mem.Allocator = null, backendNodeId: ?u32 = null, maxDepth: ?u32 = null, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { try jw.beginWriteRaw(); try jw.writer.writeByte('"'); var escaped: protocol.JsonEscapingWriter = .init(jw.writer); const w = &escaped.writer; switch (self.action) { .markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| { log.err(.mcp, "markdown dump failed", .{ .err = err }); }, .links => { if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| { defer list.deinit(self.page._session); var first = true; for (list._nodes) |node| { if (node.is(Element.Html.Anchor)) |anchor| { const href = anchor.getHref(self.page) catch |err| { log.err(.mcp, "resolve href failed", .{ .err = err }); continue; }; if (href.len > 0) { if (!first) try w.writeByte('\n'); try w.writeAll(href); first = false; } } } } else |err| { log.err(.mcp, "query links failed", .{ .err = err }); } }, .semantic_tree => { var root_node = self.page.document.asNode(); if (self.backendNodeId) |node_id| { if (self.registry) |registry| { if (registry.lookup_by_id.get(node_id)) |n| { root_node = n.dom; } else { log.warn(.mcp, "semantic_tree id missing", .{ .id = node_id }); } } } const st = lp.SemanticTree{ .dom_node = root_node, .registry = self.registry.?, .page = self.page, .arena = self.arena.?, .prune = true, .max_depth = self.maxDepth orelse std.math.maxInt(u32) - 1, }; st.textStringify(w) catch |err| { log.err(.mcp, "semantic tree dump failed", .{ .err = err }); }; }, } try jw.writer.writeByte('"'); jw.endWriteRaw(); } }; const ToolAction = enum { goto, navigate, markdown, links, interactiveElements, structuredData, evaluate, semantic_tree, click, fill, scroll, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "goto", .goto }, .{ "navigate", .navigate }, .{ "markdown", .markdown }, .{ "links", .links }, .{ "interactiveElements", .interactiveElements }, .{ "structuredData", .structuredData }, .{ "evaluate", .evaluate }, .{ "semantic_tree", .semantic_tree }, .{ "click", .click }, .{ "fill", .fill }, .{ "scroll", .scroll }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { if (req.params == null or req.id == null) { return server.sendError(req.id orelse .{ .integer = -1 }, .InvalidParams, "Missing params"); } const CallParams = struct { name: []const u8, arguments: ?std.json.Value = null, }; const call_params = std.json.parseFromValueLeaky(CallParams, arena, req.params.?, .{ .ignore_unknown_fields = true }) catch { return server.sendError(req.id.?, .InvalidParams, "Invalid params"); }; const action = tool_map.get(call_params.name) orelse { return server.sendError(req.id.?, .MethodNotFound, "Tool not found"); }; switch (action) { .goto, .navigate => try handleGoto(server, arena, req.id.?, call_params.arguments), .markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments), .links => try handleLinks(server, arena, req.id.?, call_params.arguments), .interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments), .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), .click => try handleClick(server, arena, req.id.?, call_params.arguments), .fill => try handleFill(server, arena, req.id.?, call_params.arguments), .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), } } fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const args = try parseArguments(GotoParams, arena, arguments, server, id, "goto"); try performGoto(server, args.url, id); const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const MarkdownParams = struct { url: ?[:0]const u8 = null, }; if (arguments) |args_raw| { if (std.json.parseFromValueLeaky(MarkdownParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { if (args.url) |u| { try performGoto(server, u, id); } } else |_| {} } const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ .page = page, .action = .markdown }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const LinksParams = struct { url: ?[:0]const u8 = null, }; if (arguments) |args_raw| { if (std.json.parseFromValueLeaky(LinksParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { if (args.url) |u| { try performGoto(server, u, id); } } else |_| {} } const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ .page = page, .action = .links }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const TreeParams = struct { url: ?[:0]const u8 = null, backendNodeId: ?u32 = null, maxDepth: ?u32 = null, }; var tree_args: TreeParams = .{}; if (arguments) |args_raw| { if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { tree_args = args; if (args.url) |u| { try performGoto(server, u, id); } } else |_| {} } const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const content = [_]protocol.TextContent(ToolStreamingText){.{ .text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const Params = struct { url: ?[:0]const u8 = null, }; if (arguments) |args_raw| { if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { if (args.url) |u| { try performGoto(server, u, id); } } else |_| {} } const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { log.err(.mcp, "elements collection failed", .{ .err = err }); return server.sendError(id, .InternalError, "Failed to collect interactive elements"); }; var aw: std.Io.Writer.Allocating = .init(arena); try std.json.Stringify.value(elements, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const Params = struct { url: ?[:0]const u8 = null, }; if (arguments) |args_raw| { if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { if (args.url) |u| { try performGoto(server, u, id); } } else |_| {} } const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| { log.err(.mcp, "struct data collection failed", .{ .err = err }); return server.sendError(id, .InternalError, "Failed to collect structured data"); }; var aw: std.Io.Writer.Allocating = .init(arena); try std.json.Stringify.value(data, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate"); if (args.url) |url| { try performGoto(server, url, id); } const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var try_catch: js.TryCatch = undefined; try_catch.init(&ls.local); defer try_catch.deinit(); const js_result = ls.local.compileAndRun(args.script, null) catch |err| { const caught = try_catch.caughtOrError(arena, err); var aw: std.Io.Writer.Allocating = .init(arena); try caught.format(&aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content, .isError = true }); }; const str_result = js_result.toStringSliceWithAlloc(arena) catch "undefined"; const content = [_]protocol.TextContent([]const u8){.{ .text = str_result }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const ClickParams = struct { backendNodeId: CDPNode.Id, }; const args = try parseArguments(ClickParams, arena, arguments, server, id, "click"); const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { return server.sendError(id, .InvalidParams, "Node not found"); }; lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an HTML element"); } return server.sendError(id, .InternalError, "Failed to click element"); }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const FillParams = struct { backendNodeId: CDPNode.Id, text: []const u8, }; const args = try parseArguments(FillParams, arena, arguments, server, id, "fill"); const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { return server.sendError(id, .InvalidParams, "Node not found"); }; lp.actions.fill(node.dom, args.text, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); } return server.sendError(id, .InternalError, "Failed to fill element"); }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const ScrollParams = struct { backendNodeId: ?CDPNode.Id = null, x: ?i32 = null, y: ?i32 = null, }; const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll"); const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; var target_node: ?*DOMNode = null; if (args.backendNodeId) |node_id| { const node = server.node_registry.lookup_by_id.get(node_id) orelse { return server.sendError(id, .InvalidParams, "Node not found"); }; target_node = node.dom; } lp.actions.scroll(target_node, args.x, args.y, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an element"); } return server.sendError(id, .InternalError, "Failed to scroll"); }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { if (arguments == null) { try server.sendError(id, .InvalidParams, "Missing arguments"); return error.InvalidParams; } return std.json.parseFromValueLeaky(T, arena, arguments.?, .{ .ignore_unknown_fields = true }) catch { const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments"; try server.sendError(id, .InvalidParams, msg); return error.InvalidParams; }; } fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void { const session = server.session; if (session.page != null) { session.removePage(); } const page = try session.createPage(); page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null }, }) catch { try server.sendError(id, .InternalError, "Internal error during navigation"); return error.NavigationFailed; }; _ = server.session.wait(5000); } const testing = @import("../testing.zig"); const router = @import("router.zig"); test "MCP - evaluate error reporting" { defer testing.reset(); const allocator = testing.allocator; const app = testing.test_app; var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); defer out_alloc.deinit(); var server = try Server.init(allocator, app, &out_alloc.writer); defer server.deinit(); _ = try server.session.createPage(); const aa = testing.arena_allocator; // Call evaluate with a script that throws an error const msg = \\{ \\ "jsonrpc": "2.0", \\ "id": 1, \\ "method": "tools/call", \\ "params": { \\ "name": "evaluate", \\ "arguments": { \\ "script": "throw new Error('test error')" \\ } \\ } \\} ; try router.handleMessage(server, aa, msg); try testing.expectJson( \\{ \\ "id": 1, \\ "result": { \\ "isError": true, \\ "content": [ \\ { "type": "text" } \\ ] \\ } \\} , out_alloc.writer.buffered()); } test "MCP - Actions: click, fill, scroll" { defer testing.reset(); const allocator = testing.allocator; const app = testing.test_app; var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); defer out_alloc.deinit(); var server = try Server.init(allocator, app, &out_alloc.writer); defer server.deinit(); const aa = testing.arena_allocator; const page = try server.session.createPage(); const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); _ = server.session.wait(5000); // Test Click const btn = page.document.getElementById("btn", page).?.asNode(); const btn_id = (try server.node_registry.register(btn)).id; var btn_id_buf: [12]u8 = undefined; const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); try router.handleMessage(server, aa, click_msg); // Test Fill Input const inp = page.document.getElementById("inp", page).?.asNode(); const inp_id = (try server.node_registry.register(inp)).id; var inp_id_buf: [12]u8 = undefined; const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" }); try router.handleMessage(server, aa, fill_msg); // Test Fill Select const sel = page.document.getElementById("sel", page).?.asNode(); const sel_id = (try server.node_registry.register(sel)).id; var sel_id_buf: [12]u8 = undefined; const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" }); try router.handleMessage(server, aa, fill_sel_msg); // Test Scroll const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); const scrollbox_id = (try server.node_registry.register(scrollbox)).id; var scroll_id_buf: [12]u8 = undefined; const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); try router.handleMessage(server, aa, scroll_msg); // Evaluate assertions var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var try_catch: js.TryCatch = undefined; try_catch.init(&ls.local); defer try_catch.deinit(); const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); try testing.expect(result.isTrue()); } ================================================ FILE: src/mcp.zig ================================================ const std = @import("std"); pub const protocol = @import("mcp/protocol.zig"); pub const router = @import("mcp/router.zig"); pub const Server = @import("mcp/Server.zig"); test { std.testing.refAllDecls(@This()); } ================================================ FILE: src/network/Robots.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const builtin = @import("builtin"); const std = @import("std"); const log = @import("../log.zig"); pub const CompiledPattern = struct { pattern: []const u8, ty: enum { prefix, // "/admin/" - prefix match exact, // "/admin$" - exact match wildcard, // any pattern that contains * }, fn compile(pattern: []const u8) CompiledPattern { if (pattern.len == 0) { return .{ .pattern = pattern, .ty = .prefix, }; } const is_wildcard = std.mem.indexOfScalar(u8, pattern, '*') != null; if (is_wildcard) { return .{ .pattern = pattern, .ty = .wildcard, }; } const has_end_anchor = pattern[pattern.len - 1] == '$'; return .{ .pattern = pattern, .ty = if (has_end_anchor) .exact else .prefix, }; } }; pub const Rule = union(enum) { allow: CompiledPattern, disallow: CompiledPattern, fn allowRule(pattern: []const u8) Rule { return .{ .allow = CompiledPattern.compile(pattern) }; } fn disallowRule(pattern: []const u8) Rule { return .{ .disallow = CompiledPattern.compile(pattern) }; } }; pub const Key = enum { @"user-agent", allow, disallow, }; /// https://www.rfc-editor.org/rfc/rfc9309.html pub const Robots = @This(); pub const empty: Robots = .{ .rules = &.{} }; pub const RobotStore = struct { const RobotsEntry = union(enum) { present: Robots, absent, }; pub const RobotsMap = std.HashMapUnmanaged([]const u8, RobotsEntry, struct { const Context = @This(); pub fn hash(_: Context, value: []const u8) u32 { var key = value; var buf: [128]u8 = undefined; var h = std.hash.Wyhash.init(value.len); while (key.len >= 128) { const lower = std.ascii.lowerString(buf[0..], key[0..128]); h.update(lower); key = key[128..]; } if (key.len > 0) { const lower = std.ascii.lowerString(buf[0..key.len], key); h.update(lower); } return @truncate(h.final()); } pub fn eql(_: Context, a: []const u8, b: []const u8) bool { return std.ascii.eqlIgnoreCase(a, b); } }, 80); allocator: std.mem.Allocator, map: RobotsMap, mutex: std.Thread.Mutex = .{}, pub fn init(allocator: std.mem.Allocator) RobotStore { return .{ .allocator = allocator, .map = .empty }; } pub fn deinit(self: *RobotStore) void { self.mutex.lock(); defer self.mutex.unlock(); var iter = self.map.iterator(); while (iter.next()) |entry| { self.allocator.free(entry.key_ptr.*); switch (entry.value_ptr.*) { .present => |*robots| robots.deinit(self.allocator), .absent => {}, } } self.map.deinit(self.allocator); } pub fn get(self: *RobotStore, url: []const u8) ?RobotsEntry { self.mutex.lock(); defer self.mutex.unlock(); return self.map.get(url); } pub fn robotsFromBytes(self: *RobotStore, user_agent: []const u8, bytes: []const u8) !Robots { return try Robots.fromBytes(self.allocator, user_agent, bytes); } pub fn put(self: *RobotStore, url: []const u8, robots: Robots) !void { self.mutex.lock(); defer self.mutex.unlock(); const duped = try self.allocator.dupe(u8, url); try self.map.put(self.allocator, duped, .{ .present = robots }); } pub fn putAbsent(self: *RobotStore, url: []const u8) !void { self.mutex.lock(); defer self.mutex.unlock(); const duped = try self.allocator.dupe(u8, url); try self.map.put(self.allocator, duped, .absent); } }; rules: []const Rule, const State = struct { entry: enum { not_in_entry, in_other_entry, in_our_entry, in_wildcard_entry, }, has_rules: bool = false, }; fn freeRulesInList(allocator: std.mem.Allocator, rules: []const Rule) void { for (rules) |rule| { switch (rule) { .allow => |compiled| allocator.free(compiled.pattern), .disallow => |compiled| allocator.free(compiled.pattern), } } } fn parseRulesWithUserAgent( allocator: std.mem.Allocator, user_agent: []const u8, raw_bytes: []const u8, ) ![]Rule { var rules: std.ArrayList(Rule) = .empty; defer rules.deinit(allocator); var wildcard_rules: std.ArrayList(Rule) = .empty; defer wildcard_rules.deinit(allocator); var state: State = .{ .entry = .not_in_entry, .has_rules = false }; // https://en.wikipedia.org/wiki/Byte_order_mark const UTF8_BOM: []const u8 = &.{ 0xEF, 0xBB, 0xBF }; // Strip UTF8 BOM const bytes = if (std.mem.startsWith(u8, raw_bytes, UTF8_BOM)) raw_bytes[3..] else raw_bytes; var iter = std.mem.splitScalar(u8, bytes, '\n'); while (iter.next()) |line| { const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); // Skip all comment lines. if (std.mem.startsWith(u8, trimmed, "#")) continue; // Remove end of line comment. const true_line = if (std.mem.indexOfScalar(u8, trimmed, '#')) |pos| std.mem.trimRight(u8, trimmed[0..pos], &std.ascii.whitespace) else trimmed; if (true_line.len == 0) continue; const colon_idx = std.mem.indexOfScalar(u8, true_line, ':') orelse { log.warn(.browser, "robots line missing colon", .{ .line = line }); continue; }; const key_str = try std.ascii.allocLowerString(allocator, true_line[0..colon_idx]); defer allocator.free(key_str); const key = std.meta.stringToEnum(Key, key_str) orelse continue; const value = std.mem.trim(u8, true_line[colon_idx + 1 ..], &std.ascii.whitespace); switch (key) { .@"user-agent" => { if (state.has_rules) { state = .{ .entry = .not_in_entry, .has_rules = false }; } switch (state.entry) { .in_other_entry => { if (std.ascii.eqlIgnoreCase(user_agent, value)) { state.entry = .in_our_entry; } }, .in_our_entry => {}, .in_wildcard_entry => { if (std.ascii.eqlIgnoreCase(user_agent, value)) { state.entry = .in_our_entry; } }, .not_in_entry => { if (std.ascii.eqlIgnoreCase(user_agent, value)) { state.entry = .in_our_entry; } else if (std.mem.eql(u8, "*", value)) { state.entry = .in_wildcard_entry; } else { state.entry = .in_other_entry; } }, } }, .allow => { defer state.has_rules = true; switch (state.entry) { .in_our_entry => { const duped_value = try allocator.dupe(u8, value); errdefer allocator.free(duped_value); try rules.append(allocator, Rule.allowRule(duped_value)); }, .in_other_entry => {}, .in_wildcard_entry => { const duped_value = try allocator.dupe(u8, value); errdefer allocator.free(duped_value); try wildcard_rules.append(allocator, Rule.allowRule(duped_value)); }, .not_in_entry => { log.warn(.browser, "robots unexpected rule", .{ .rule = "allow" }); continue; }, } }, .disallow => { defer state.has_rules = true; switch (state.entry) { .in_our_entry => { if (value.len == 0) continue; const duped_value = try allocator.dupe(u8, value); errdefer allocator.free(duped_value); try rules.append(allocator, Rule.disallowRule(duped_value)); }, .in_other_entry => {}, .in_wildcard_entry => { if (value.len == 0) continue; const duped_value = try allocator.dupe(u8, value); errdefer allocator.free(duped_value); try wildcard_rules.append(allocator, Rule.disallowRule(duped_value)); }, .not_in_entry => { log.warn(.browser, "robots unexpected rule", .{ .rule = "disallow" }); continue; }, } }, } } // If we have rules for our specific User-Agent, we will use those rules. // If we don't have any rules, we fallback to using the wildcard ("*") rules. if (rules.items.len > 0) { freeRulesInList(allocator, wildcard_rules.items); return try rules.toOwnedSlice(allocator); } else { freeRulesInList(allocator, rules.items); return try wildcard_rules.toOwnedSlice(allocator); } } pub fn fromBytes(allocator: std.mem.Allocator, user_agent: []const u8, bytes: []const u8) !Robots { const rules = try parseRulesWithUserAgent(allocator, user_agent, bytes); // sort by order once. std.mem.sort(Rule, rules, {}, struct { fn lessThan(_: void, a: Rule, b: Rule) bool { const a_len = switch (a) { .allow => |p| p.pattern.len, .disallow => |p| p.pattern.len, }; const b_len = switch (b) { .allow => |p| p.pattern.len, .disallow => |p| p.pattern.len, }; // Sort by length first. if (a_len != b_len) { return a_len > b_len; } // Otherwise, allow should beat disallow. const a_is_allow = switch (a) { .allow => true, .disallow => false, }; const b_is_allow = switch (b) { .allow => true, .disallow => false, }; return a_is_allow and !b_is_allow; } }.lessThan); return .{ .rules = rules }; } pub fn deinit(self: *Robots, allocator: std.mem.Allocator) void { freeRulesInList(allocator, self.rules); allocator.free(self.rules); } /// There are rules for how the pattern in robots.txt should be matched. /// /// * should match 0 or more of any character. /// $ should signify the end of a path, making it exact. /// otherwise, it is a prefix path. fn matchPattern(compiled: CompiledPattern, path: []const u8) bool { switch (compiled.ty) { .prefix => return std.mem.startsWith(u8, path, compiled.pattern), .exact => { const pattern = compiled.pattern; return std.mem.eql(u8, path, pattern[0 .. pattern.len - 1]); }, .wildcard => { const pattern = compiled.pattern; const exact_match = pattern[pattern.len - 1] == '$'; const inner_pattern = if (exact_match) pattern[0 .. pattern.len - 1] else pattern; return matchInnerPattern(inner_pattern, path, exact_match); }, } } fn matchInnerPattern(pattern: []const u8, path: []const u8, exact_match: bool) bool { var pattern_idx: usize = 0; var path_idx: usize = 0; var star_pattern_idx: ?usize = null; var star_path_idx: ?usize = null; while (pattern_idx < pattern.len or path_idx < path.len) { // 1: If pattern is consumed and we are doing prefix match, we matched. if (pattern_idx >= pattern.len and !exact_match) { return true; } // 2: Current character is a wildcard if (pattern_idx < pattern.len and pattern[pattern_idx] == '*') { star_pattern_idx = pattern_idx; star_path_idx = path_idx; pattern_idx += 1; continue; } // 3: Characters match, advance both heads. if (pattern_idx < pattern.len and path_idx < path.len and pattern[pattern_idx] == path[path_idx]) { pattern_idx += 1; path_idx += 1; continue; } // 4: we have a previous wildcard, backtrack and try matching more. if (star_pattern_idx) |star_p_idx| { // if we have exhausted the path, // we know we haven't matched. if (star_path_idx.? > path.len) { return false; } pattern_idx = star_p_idx + 1; path_idx = star_path_idx.?; star_path_idx.? += 1; continue; } // Fallthrough: No match and no backtracking. return false; } // Handle trailing widlcards that can match 0 characters. while (pattern_idx < pattern.len and pattern[pattern_idx] == '*') { pattern_idx += 1; } if (exact_match) { // Both must be fully consumed. return pattern_idx == pattern.len and path_idx == path.len; } // For prefix match, pattern must be completed. return pattern_idx == pattern.len; } pub fn isAllowed(self: *const Robots, path: []const u8) bool { for (self.rules) |rule| { switch (rule) { .allow => |compiled| if (matchPattern(compiled, path)) return true, .disallow => |compiled| if (matchPattern(compiled, path)) return false, } } return true; } fn testMatch(pattern: []const u8, path: []const u8) bool { comptime if (!builtin.is_test) unreachable; return matchPattern(CompiledPattern.compile(pattern), path); } test "Robots: simple robots.txt" { const allocator = std.testing.allocator; const file = \\User-agent: * \\Disallow: /private/ \\Allow: /public/ \\ \\User-agent: Googlebot \\Disallow: /admin/ \\ ; const rules = try parseRulesWithUserAgent(allocator, "GoogleBot", file); defer { freeRulesInList(allocator, rules); allocator.free(rules); } try std.testing.expectEqual(1, rules.len); try std.testing.expectEqualStrings("/admin/", rules[0].disallow.pattern); } test "Robots: matchPattern - simple prefix" { try std.testing.expect(testMatch("/admin", "/admin/page")); try std.testing.expect(testMatch("/admin", "/admin")); try std.testing.expect(!testMatch("/admin", "/other")); try std.testing.expect(!testMatch("/admin/page", "/admin")); } test "Robots: matchPattern - single wildcard" { try std.testing.expect(testMatch("/admin/*", "/admin/")); try std.testing.expect(testMatch("/admin/*", "/admin/page")); try std.testing.expect(testMatch("/admin/*", "/admin/page/subpage")); try std.testing.expect(!testMatch("/admin/*", "/other/page")); } test "Robots: matchPattern - wildcard in middle" { try std.testing.expect(testMatch("/abc/*/xyz", "/abc/def/xyz")); try std.testing.expect(testMatch("/abc/*/xyz", "/abc/def/ghi/xyz")); try std.testing.expect(!testMatch("/abc/*/xyz", "/abc/def")); try std.testing.expect(!testMatch("/abc/*/xyz", "/other/def/xyz")); } test "Robots: matchPattern - complex wildcard case" { try std.testing.expect(testMatch("/abc/*/def/xyz", "/abc/def/def/xyz")); try std.testing.expect(testMatch("/abc/*/def/xyz", "/abc/ANYTHING/def/xyz")); } test "Robots: matchPattern - multiple wildcards" { try std.testing.expect(testMatch("/a/*/b/*/c", "/a/x/b/y/c")); try std.testing.expect(testMatch("/a/*/b/*/c", "/a/x/y/b/z/w/c")); try std.testing.expect(testMatch("/*.php", "/index.php")); try std.testing.expect(testMatch("/*.php", "/admin/index.php")); } test "Robots: matchPattern - end anchor" { try std.testing.expect(testMatch("/*.php$", "/index.php")); try std.testing.expect(!testMatch("/*.php$", "/index.php?param=value")); try std.testing.expect(testMatch("/admin$", "/admin")); try std.testing.expect(!testMatch("/admin$", "/admin/")); try std.testing.expect(testMatch("/fish$", "/fish")); try std.testing.expect(!testMatch("/fish$", "/fishheads")); } test "Robots: matchPattern - wildcard with extension" { try std.testing.expect(testMatch("/fish*.php", "/fish.php")); try std.testing.expect(testMatch("/fish*.php", "/fishheads.php")); try std.testing.expect(testMatch("/fish*.php", "/fish/salmon.php")); try std.testing.expect(!testMatch("/fish*.php", "/fish.asp")); } test "Robots: matchPattern - empty and edge cases" { try std.testing.expect(testMatch("", "/anything")); try std.testing.expect(testMatch("/", "/")); try std.testing.expect(testMatch("*", "/anything")); try std.testing.expect(testMatch("/*", "/anything")); try std.testing.expect(testMatch("$", "")); } test "Robots: matchPattern - real world examples" { try std.testing.expect(testMatch("/", "/anything")); try std.testing.expect(testMatch("/admin/", "/admin/page")); try std.testing.expect(!testMatch("/admin/", "/public/page")); try std.testing.expect(testMatch("/*.pdf$", "/document.pdf")); try std.testing.expect(!testMatch("/*.pdf$", "/document.pdf.bak")); try std.testing.expect(testMatch("/*?", "/page?param=value")); try std.testing.expect(!testMatch("/*?", "/page")); } test "Robots: isAllowed - basic allow/disallow" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "MyBot", \\User-agent: MyBot \\Disallow: /admin/ \\Allow: /public/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/") == true); try std.testing.expect(robots.isAllowed("/public/page") == true); try std.testing.expect(robots.isAllowed("/admin/secret") == false); try std.testing.expect(robots.isAllowed("/other/page") == true); } test "Robots: isAllowed - longest match wins" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "TestBot", \\User-agent: TestBot \\Disallow: /admin/ \\Allow: /admin/public/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/admin/secret") == false); try std.testing.expect(robots.isAllowed("/admin/public/page") == true); try std.testing.expect(robots.isAllowed("/admin/public/") == true); } test "Robots: isAllowed - specific user-agent vs wildcard" { const allocator = std.testing.allocator; var robots1 = try Robots.fromBytes(allocator, "Googlebot", \\User-agent: Googlebot \\Disallow: /private/ \\ \\User-agent: * \\Disallow: /admin/ \\ ); defer robots1.deinit(allocator); try std.testing.expect(robots1.isAllowed("/private/page") == false); try std.testing.expect(robots1.isAllowed("/admin/page") == true); // Test with other bot (should use wildcard) var robots2 = try Robots.fromBytes(allocator, "OtherBot", \\User-agent: Googlebot \\Disallow: /private/ \\ \\User-agent: * \\Disallow: /admin/ \\ ); defer robots2.deinit(allocator); try std.testing.expect(robots2.isAllowed("/private/page") == true); try std.testing.expect(robots2.isAllowed("/admin/page") == false); } test "Robots: isAllowed - case insensitive user-agent" { const allocator = std.testing.allocator; var robots1 = try Robots.fromBytes(allocator, "googlebot", \\User-agent: GoogleBot \\Disallow: /private/ \\ ); defer robots1.deinit(allocator); try std.testing.expect(robots1.isAllowed("/private/") == false); var robots2 = try Robots.fromBytes(allocator, "GOOGLEBOT", \\User-agent: GoogleBot \\Disallow: /private/ \\ ); defer robots2.deinit(allocator); try std.testing.expect(robots2.isAllowed("/private/") == false); var robots3 = try Robots.fromBytes(allocator, "GoOgLeBoT", \\User-agent: GoogleBot \\Disallow: /private/ \\ ); defer robots3.deinit(allocator); try std.testing.expect(robots3.isAllowed("/private/") == false); } test "Robots: isAllowed - merged rules for same agent" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Googlebot", \\User-agent: Googlebot \\Disallow: /admin/ \\ \\User-agent: Googlebot \\Disallow: /private/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/admin/page") == false); try std.testing.expect(robots.isAllowed("/private/page") == false); try std.testing.expect(robots.isAllowed("/public/page") == true); } test "Robots: isAllowed - wildcards in patterns" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Bot", \\User-agent: Bot \\Disallow: /*.php$ \\Allow: /index.php$ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/page.php") == false); try std.testing.expect(robots.isAllowed("/index.php") == true); try std.testing.expect(robots.isAllowed("/page.php?param=1") == true); try std.testing.expect(robots.isAllowed("/page.html") == true); } test "Robots: isAllowed - empty disallow allows everything" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Bot", \\User-agent: Bot \\Disallow: \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/anything") == true); try std.testing.expect(robots.isAllowed("/") == true); } test "Robots: isAllowed - no rules" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Bot", ""); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/anything") == true); } test "Robots: isAllowed - disallow all" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Bot", \\User-agent: Bot \\Disallow: / \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/") == false); try std.testing.expect(robots.isAllowed("/anything") == false); try std.testing.expect(robots.isAllowed("/admin/page") == false); } test "Robots: isAllowed - multiple user-agents in same entry" { const allocator = std.testing.allocator; var robots1 = try Robots.fromBytes(allocator, "Googlebot", \\User-agent: Googlebot \\User-agent: Bingbot \\Disallow: /private/ \\ ); defer robots1.deinit(allocator); try std.testing.expect(robots1.isAllowed("/private/") == false); var robots2 = try Robots.fromBytes(allocator, "Bingbot", \\User-agent: Googlebot \\User-agent: Bingbot \\Disallow: /private/ \\ ); defer robots2.deinit(allocator); try std.testing.expect(robots2.isAllowed("/private/") == false); var robots3 = try Robots.fromBytes(allocator, "OtherBot", \\User-agent: Googlebot \\User-agent: Bingbot \\Disallow: /private/ \\ ); defer robots3.deinit(allocator); try std.testing.expect(robots3.isAllowed("/private/") == true); } test "Robots: isAllowed - wildcard fallback" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "UnknownBot", \\User-agent: * \\Disallow: /admin/ \\Allow: /admin/public/ \\ \\User-agent: Googlebot \\Disallow: /private/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/admin/secret") == false); try std.testing.expect(robots.isAllowed("/admin/public/page") == true); try std.testing.expect(robots.isAllowed("/private/") == true); } test "Robots: isAllowed - complex real-world example" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "MyBot", \\User-agent: * \\Disallow: /cgi-bin/ \\Disallow: /tmp/ \\Disallow: /private/ \\ \\User-agent: MyBot \\Disallow: /admin/ \\Disallow: /*.pdf$ \\Allow: /public/*.pdf$ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/") == true); try std.testing.expect(robots.isAllowed("/admin/dashboard") == false); try std.testing.expect(robots.isAllowed("/docs/guide.pdf") == false); try std.testing.expect(robots.isAllowed("/public/manual.pdf") == true); try std.testing.expect(robots.isAllowed("/page.html") == true); try std.testing.expect(robots.isAllowed("/cgi-bin/script.sh") == true); } test "Robots: isAllowed - order doesn't matter + allow wins" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Bot", \\User-agent: Bot \\ # WOW!! \\Allow: /page \\Disallow: /page \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/page") == true); } test "Robots: isAllowed - empty file uses wildcard defaults" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "MyBot", \\User-agent: * # ABCDEF!!! \\Disallow: /admin/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/admin/") == false); try std.testing.expect(robots.isAllowed("/public/") == true); } test "Robots: isAllowed - wildcard entry with multiple user-agents including specific" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Googlebot", \\User-agent: * \\User-agent: Googlebot \\Disallow: /shared/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/shared/") == false); try std.testing.expect(robots.isAllowed("/other/") == true); var robots2 = try Robots.fromBytes(allocator, "Bingbot", \\User-agent: * \\User-agent: Googlebot \\Disallow: /shared/ \\ ); defer robots2.deinit(allocator); try std.testing.expect(robots2.isAllowed("/shared/") == false); } test "Robots: isAllowed - specific agent appears after wildcard in entry" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "MyBot", \\User-agent: * \\User-agent: MyBot \\User-agent: Bingbot \\Disallow: /admin/ \\Allow: /admin/public/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/admin/secret") == false); try std.testing.expect(robots.isAllowed("/admin/public/page") == true); } test "Robots: isAllowed - wildcard should not override specific entry" { const allocator = std.testing.allocator; var robots = try Robots.fromBytes(allocator, "Googlebot", \\User-agent: Googlebot \\Disallow: /private/ \\ \\User-agent: * \\User-agent: Googlebot \\Disallow: /admin/ \\ ); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/private/") == false); try std.testing.expect(robots.isAllowed("/admin/") == false); } test "Robots: isAllowed - Google's real robots.txt" { const allocator = std.testing.allocator; // Simplified version of google.com/robots.txt const google_robots = \\User-agent: * \\User-agent: Yandex \\Disallow: /search \\Allow: /search/about \\Allow: /search/howsearchworks \\Disallow: /imgres \\Disallow: /m? \\Disallow: /m/ \\Allow: /m/finance \\Disallow: /maps/ \\Allow: /maps/$ \\Allow: /maps/@ \\Allow: /maps/dir/ \\Disallow: /shopping? \\Allow: /shopping?udm=28$ \\ \\User-agent: AdsBot-Google \\Disallow: /maps/api/js/ \\Allow: /maps/api/js \\Disallow: /maps/api/staticmap \\ \\User-agent: Yandex \\Disallow: /about/careers/applications/jobs/results \\ \\User-agent: facebookexternalhit \\User-agent: Twitterbot \\Allow: /imgres \\Allow: /search \\Disallow: /groups \\Disallow: /m/ \\ ; var regular_bot = try Robots.fromBytes(allocator, "Googlebot", google_robots); defer regular_bot.deinit(allocator); try std.testing.expect(regular_bot.isAllowed("/") == true); try std.testing.expect(regular_bot.isAllowed("/search") == false); try std.testing.expect(regular_bot.isAllowed("/search/about") == true); try std.testing.expect(regular_bot.isAllowed("/search/howsearchworks") == true); try std.testing.expect(regular_bot.isAllowed("/imgres") == false); try std.testing.expect(regular_bot.isAllowed("/m/finance") == true); try std.testing.expect(regular_bot.isAllowed("/m/other") == false); try std.testing.expect(regular_bot.isAllowed("/maps/") == true); try std.testing.expect(regular_bot.isAllowed("/maps/@") == true); try std.testing.expect(regular_bot.isAllowed("/shopping?udm=28") == true); try std.testing.expect(regular_bot.isAllowed("/shopping?udm=28&extra") == false); var adsbot = try Robots.fromBytes(allocator, "AdsBot-Google", google_robots); defer adsbot.deinit(allocator); try std.testing.expect(adsbot.isAllowed("/maps/api/js") == true); try std.testing.expect(adsbot.isAllowed("/maps/api/js/") == false); try std.testing.expect(adsbot.isAllowed("/maps/api/staticmap") == false); var twitterbot = try Robots.fromBytes(allocator, "Twitterbot", google_robots); defer twitterbot.deinit(allocator); try std.testing.expect(twitterbot.isAllowed("/imgres") == true); try std.testing.expect(twitterbot.isAllowed("/search") == true); try std.testing.expect(twitterbot.isAllowed("/groups") == false); try std.testing.expect(twitterbot.isAllowed("/m/") == false); } test "Robots: user-agent after rules starts new entry" { const allocator = std.testing.allocator; const file = \\User-agent: Bot1 \\User-agent: Bot2 \\Disallow: /admin/ \\Allow: /public/ \\User-agent: Bot3 \\Disallow: /private/ \\ ; var robots1 = try Robots.fromBytes(allocator, "Bot1", file); defer robots1.deinit(allocator); try std.testing.expect(robots1.isAllowed("/admin/") == false); try std.testing.expect(robots1.isAllowed("/public/") == true); try std.testing.expect(robots1.isAllowed("/private/") == true); var robots2 = try Robots.fromBytes(allocator, "Bot2", file); defer robots2.deinit(allocator); try std.testing.expect(robots2.isAllowed("/admin/") == false); try std.testing.expect(robots2.isAllowed("/public/") == true); try std.testing.expect(robots2.isAllowed("/private/") == true); var robots3 = try Robots.fromBytes(allocator, "Bot3", file); defer robots3.deinit(allocator); try std.testing.expect(robots3.isAllowed("/admin/") == true); try std.testing.expect(robots3.isAllowed("/public/") == true); try std.testing.expect(robots3.isAllowed("/private/") == false); } test "Robots: blank lines don't end entries" { const allocator = std.testing.allocator; const file = \\User-agent: MyBot \\Disallow: /admin/ \\ \\ \\Allow: /public/ \\ ; var robots = try Robots.fromBytes(allocator, "MyBot", file); defer robots.deinit(allocator); try std.testing.expect(robots.isAllowed("/admin/") == false); try std.testing.expect(robots.isAllowed("/public/") == true); } ================================================ FILE: src/network/Runtime.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); const net = std.net; const posix = std.posix; const Allocator = std.mem.Allocator; const lp = @import("lightpanda"); const Config = @import("../Config.zig"); const libcurl = @import("../sys/libcurl.zig"); const net_http = @import("http.zig"); const RobotStore = @import("Robots.zig").RobotStore; const WebBotAuth = @import("WebBotAuth.zig"); const Runtime = @This(); const Listener = struct { socket: posix.socket_t, ctx: *anyopaque, onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, }; // Number of fixed pollfds entries (wakeup pipe + listener). const PSEUDO_POLLFDS = 2; const MAX_TICK_CALLBACKS = 16; allocator: Allocator, config: *const Config, ca_blob: ?net_http.Blob, robot_store: RobotStore, web_bot_auth: ?WebBotAuth, connections: []net_http.Connection, available: std.DoublyLinkedList = .{}, conn_mutex: std.Thread.Mutex = .{}, pollfds: []posix.pollfd, listener: ?Listener = null, // Wakeup pipe: workers write to [1], main thread polls [0] wakeup_pipe: [2]posix.fd_t = .{ -1, -1 }, shutdown: std.atomic.Value(bool) = .init(false), // Multi is a heavy structure that can consume up to 2MB of RAM. // Currently, Runtime is used sparingly, and we only create it on demand. // When Runtime becomes truly shared, it should become a regular field. multi: ?*libcurl.CurlM = null, submission_mutex: std.Thread.Mutex = .{}, submission_queue: std.DoublyLinkedList = .{}, callbacks: [MAX_TICK_CALLBACKS]TickCallback = undefined, callbacks_len: usize = 0, callbacks_mutex: std.Thread.Mutex = .{}, const TickCallback = struct { ctx: *anyopaque, fun: *const fn (*anyopaque) void, }; const ZigToCurlAllocator = struct { // C11 requires malloc to return memory aligned to max_align_t (16 bytes on x86_64). // We match this guarantee since libcurl expects malloc-compatible alignment. const alignment = 16; const Block = extern struct { size: usize = 0, _padding: [alignment - @sizeOf(usize)]u8 = .{0} ** (alignment - @sizeOf(usize)), inline fn fullsize(bytes: usize) usize { return alignment + bytes; } inline fn fromPtr(ptr: *anyopaque) *Block { const raw: [*]u8 = @ptrCast(ptr); return @ptrCast(@alignCast(raw - @sizeOf(Block))); } inline fn data(self: *Block) [*]u8 { const ptr: [*]u8 = @ptrCast(self); return ptr + @sizeOf(Block); } inline fn slice(self: *Block) []align(alignment) u8 { const base: [*]align(alignment) u8 = @ptrCast(@alignCast(self)); return base[0 .. alignment + self.size]; } }; comptime { std.debug.assert(@sizeOf(Block) == alignment); } var instance: ?ZigToCurlAllocator = null; allocator: Allocator, pub fn init(allocator: Allocator) void { lp.assert(instance == null, "Initialization of curl must happen only once", .{}); instance = .{ .allocator = allocator }; } pub fn interface() libcurl.CurlAllocator { return .{ .free = free, .strdup = strdup, .malloc = malloc, .calloc = calloc, .realloc = realloc, }; } fn _allocBlock(size: usize) ?*Block { const slice = instance.?.allocator.alignedAlloc(u8, .fromByteUnits(alignment), Block.fullsize(size)) catch return null; const block: *Block = @ptrCast(@alignCast(slice.ptr)); block.size = size; return block; } fn _freeBlock(header: *Block) void { instance.?.allocator.free(header.slice()); } fn malloc(size: usize) ?*anyopaque { const block = _allocBlock(size) orelse return null; return @ptrCast(block.data()); } fn calloc(nmemb: usize, size: usize) ?*anyopaque { const total = nmemb * size; const block = _allocBlock(total) orelse return null; const ptr = block.data(); @memset(ptr[0..total], 0); // for historical reasons, calloc zeroes memory, but malloc does not. return @ptrCast(ptr); } fn realloc(ptr: ?*anyopaque, size: usize) ?*anyopaque { const p = ptr orelse return malloc(size); const block = Block.fromPtr(p); const old_size = block.size; if (size == old_size) return ptr; if (instance.?.allocator.resize(block.slice(), alignment + size)) { block.size = size; return ptr; } const copy_size = @min(old_size, size); const new_block = _allocBlock(size) orelse return null; @memcpy(new_block.data()[0..copy_size], block.data()[0..copy_size]); _freeBlock(block); return @ptrCast(new_block.data()); } fn free(ptr: ?*anyopaque) void { const p = ptr orelse return; _freeBlock(Block.fromPtr(p)); } fn strdup(str: [*:0]const u8) ?[*:0]u8 { const len = std.mem.len(str); const header = _allocBlock(len + 1) orelse return null; const ptr = header.data(); @memcpy(ptr[0..len], str[0..len]); ptr[len] = 0; return ptr[0..len :0]; } }; fn globalInit(allocator: Allocator) void { ZigToCurlAllocator.init(allocator); libcurl.curl_global_init(.{ .ssl = true }, ZigToCurlAllocator.interface()) catch |err| { lp.assert(false, "curl global init", .{ .err = err }); }; } fn globalDeinit() void { libcurl.curl_global_cleanup(); } pub fn init(allocator: Allocator, config: *const Config) !Runtime { globalInit(allocator); errdefer globalDeinit(); const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); // 0 is wakeup, 1 is listener, rest for curl fds const pollfds = try allocator.alloc(posix.pollfd, PSEUDO_POLLFDS + config.httpMaxConcurrent()); errdefer allocator.free(pollfds); @memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 }; var ca_blob: ?net_http.Blob = null; if (config.tlsVerifyHost()) { ca_blob = try loadCerts(allocator); } const count: usize = config.httpMaxConcurrent(); const connections = try allocator.alloc(net_http.Connection, count); errdefer allocator.free(connections); var available: std.DoublyLinkedList = .{}; for (0..count) |i| { connections[i] = try net_http.Connection.init(ca_blob, config); available.append(&connections[i].node); } const web_bot_auth = if (config.webBotAuth()) |wba_cfg| try WebBotAuth.fromConfig(allocator, &wba_cfg) else null; return .{ .allocator = allocator, .config = config, .ca_blob = ca_blob, .pollfds = pollfds, .wakeup_pipe = pipe, .available = available, .connections = connections, .robot_store = RobotStore.init(allocator), .web_bot_auth = web_bot_auth, }; } pub fn deinit(self: *Runtime) void { if (self.multi) |multi| { libcurl.curl_multi_cleanup(multi) catch {}; } for (&self.wakeup_pipe) |*fd| { if (fd.* >= 0) { posix.close(fd.*); fd.* = -1; } } self.allocator.free(self.pollfds); if (self.ca_blob) |ca_blob| { const data: [*]u8 = @ptrCast(ca_blob.data); self.allocator.free(data[0..ca_blob.len]); } for (self.connections) |*conn| { conn.deinit(); } self.allocator.free(self.connections); self.robot_store.deinit(); if (self.web_bot_auth) |wba| { wba.deinit(self.allocator); } globalDeinit(); } pub fn bind( self: *Runtime, address: net.Address, ctx: *anyopaque, on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, ) !void { const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); errdefer posix.close(listener); try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); if (@hasDecl(posix.TCP, "NODELAY")) { try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); } try posix.bind(listener, &address.any, address.getOsSockLen()); try posix.listen(listener, self.config.maxPendingConnections()); if (self.listener != null) return error.TooManyListeners; self.listener = .{ .socket = listener, .ctx = ctx, .onAccept = on_accept, }; self.pollfds[1] = .{ .fd = listener, .events = posix.POLL.IN, .revents = 0, }; } pub fn onTick(self: *Runtime, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void { self.callbacks_mutex.lock(); defer self.callbacks_mutex.unlock(); lp.assert(self.callbacks_len < MAX_TICK_CALLBACKS, "too many ticks", .{}); self.callbacks[self.callbacks_len] = .{ .ctx = ctx, .fun = callback, }; self.callbacks_len += 1; self.wakeupPoll(); } pub fn fireTicks(self: *Runtime) void { self.callbacks_mutex.lock(); defer self.callbacks_mutex.unlock(); for (self.callbacks[0..self.callbacks_len]) |*callback| { callback.fun(callback.ctx); } } pub fn run(self: *Runtime) void { var drain_buf: [64]u8 = undefined; var running_handles: c_int = 0; const poll_fd = &self.pollfds[0]; const listen_fd = &self.pollfds[1]; // Please note that receiving a shutdown command does not terminate all connections. // When gracefully shutting down a server, we at least want to send the remaining // telemetry, but we stop accepting new connections. It is the responsibility // of external code to terminate its requests upon shutdown. while (true) { self.drainQueue(); if (self.multi) |multi| { // Kickstart newly added handles (DNS/connect) so that // curl registers its sockets before we poll. libcurl.curl_multi_perform(multi, &running_handles) catch |err| { lp.log.err(.app, "curl perform", .{ .err = err }); }; self.preparePollFds(multi); } // for ontick to work, you need to wake up periodically const timeout = blk: { const min_timeout = 250; // 250ms if (self.multi == null) { break :blk min_timeout; } const curl_timeout = self.getCurlTimeout(); if (curl_timeout == 0) { break :blk min_timeout; } break :blk @min(min_timeout, curl_timeout); }; _ = posix.poll(self.pollfds, timeout) catch |err| { lp.log.err(.app, "poll", .{ .err = err }); continue; }; // check wakeup pipe if (poll_fd.revents != 0) { poll_fd.revents = 0; while (true) _ = posix.read(self.wakeup_pipe[0], &drain_buf) catch break; } // accept new connections if (listen_fd.revents != 0) { listen_fd.revents = 0; self.acceptConnections(); } if (self.multi) |multi| { // Drive transfers and process completions. libcurl.curl_multi_perform(multi, &running_handles) catch |err| { lp.log.err(.app, "curl perform", .{ .err = err }); }; self.processCompletions(multi); } self.fireTicks(); if (self.shutdown.load(.acquire) and running_handles == 0) { // Check if fireTicks submitted new requests (e.g. telemetry flush). // If so, continue the loop to drain and send them before exiting. self.submission_mutex.lock(); const has_pending = self.submission_queue.first != null; self.submission_mutex.unlock(); if (!has_pending) break; } } if (self.listener) |listener| { posix.shutdown(listener.socket, .both) catch |err| blk: { if (err == error.SocketNotConnected and builtin.os.tag != .linux) { // This error is normal/expected on BSD/MacOS. We probably // shouldn't bother calling shutdown at all, but I guess this // is safer. break :blk; } lp.log.warn(.app, "listener shutdown", .{ .err = err }); }; posix.close(listener.socket); } } pub fn submitRequest(self: *Runtime, conn: *net_http.Connection) void { self.submission_mutex.lock(); self.submission_queue.append(&conn.node); self.submission_mutex.unlock(); self.wakeupPoll(); } fn wakeupPoll(self: *Runtime) void { _ = posix.write(self.wakeup_pipe[1], &.{1}) catch {}; } fn drainQueue(self: *Runtime) void { self.submission_mutex.lock(); defer self.submission_mutex.unlock(); if (self.submission_queue.first == null) return; const multi = self.multi orelse blk: { const m = libcurl.curl_multi_init() orelse { lp.assert(false, "curl multi init failed", .{}); unreachable; }; self.multi = m; break :blk m; }; while (self.submission_queue.popFirst()) |node| { const conn: *net_http.Connection = @fieldParentPtr("node", node); conn.setPrivate(conn) catch |err| { lp.log.err(.app, "curl set private", .{ .err = err }); self.releaseConnection(conn); continue; }; libcurl.curl_multi_add_handle(multi, conn.easy) catch |err| { lp.log.err(.app, "curl multi add", .{ .err = err }); self.releaseConnection(conn); }; } } pub fn stop(self: *Runtime) void { self.shutdown.store(true, .release); self.wakeupPoll(); } fn acceptConnections(self: *Runtime) void { if (self.shutdown.load(.acquire)) { return; } const listener = self.listener orelse return; while (true) { const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { switch (err) { error.WouldBlock => break, error.SocketNotListening => { self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 }; self.listener = null; return; }, error.ConnectionAborted => { lp.log.warn(.app, "accept connection aborted", .{}); continue; }, else => { lp.log.err(.app, "accept error", .{ .err = err }); continue; }, } }; listener.onAccept(listener.ctx, socket); } } fn preparePollFds(self: *Runtime, multi: *libcurl.CurlM) void { const curl_fds = self.pollfds[PSEUDO_POLLFDS..]; @memset(curl_fds, .{ .fd = -1, .events = 0, .revents = 0 }); var fd_count: c_uint = 0; const wait_fds: []libcurl.CurlWaitFd = @ptrCast(curl_fds); libcurl.curl_multi_waitfds(multi, wait_fds, &fd_count) catch |err| { lp.log.err(.app, "curl waitfds", .{ .err = err }); }; } fn getCurlTimeout(self: *Runtime) i32 { const multi = self.multi orelse return -1; var timeout_ms: c_long = -1; libcurl.curl_multi_timeout(multi, &timeout_ms) catch return -1; return @intCast(@min(timeout_ms, std.math.maxInt(i32))); } fn processCompletions(self: *Runtime, multi: *libcurl.CurlM) void { var msgs_in_queue: c_int = 0; while (libcurl.curl_multi_info_read(multi, &msgs_in_queue)) |msg| { switch (msg.data) { .done => |maybe_err| { if (maybe_err) |err| { lp.log.warn(.app, "curl transfer error", .{ .err = err }); } }, else => continue, } const easy: *libcurl.Curl = msg.easy_handle; var ptr: *anyopaque = undefined; libcurl.curl_easy_getinfo(easy, .private, &ptr) catch lp.assert(false, "curl getinfo private", .{}); const conn: *net_http.Connection = @ptrCast(@alignCast(ptr)); libcurl.curl_multi_remove_handle(multi, easy) catch {}; self.releaseConnection(conn); } } comptime { if (@sizeOf(posix.pollfd) != @sizeOf(libcurl.CurlWaitFd)) { @compileError("pollfd and CurlWaitFd size mismatch"); } if (@offsetOf(posix.pollfd, "fd") != @offsetOf(libcurl.CurlWaitFd, "fd") or @offsetOf(posix.pollfd, "events") != @offsetOf(libcurl.CurlWaitFd, "events") or @offsetOf(posix.pollfd, "revents") != @offsetOf(libcurl.CurlWaitFd, "revents")) { @compileError("pollfd and CurlWaitFd layout mismatch"); } } pub fn getConnection(self: *Runtime) ?*net_http.Connection { self.conn_mutex.lock(); defer self.conn_mutex.unlock(); const node = self.available.popFirst() orelse return null; return @fieldParentPtr("node", node); } pub fn releaseConnection(self: *Runtime, conn: *net_http.Connection) void { conn.reset() catch |err| { lp.assert(false, "couldn't reset curl easy", .{ .err = err }); }; self.conn_mutex.lock(); defer self.conn_mutex.unlock(); self.available.append(&conn.node); } pub fn newConnection(self: *Runtime) !net_http.Connection { return net_http.Connection.init(self.ca_blob, self.config); } // Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is // what Zig has), with lines wrapped at 64 characters and with a basic header // and footer const LineWriter = struct { col: usize = 0, inner: std.ArrayList(u8).Writer, pub fn writeAll(self: *LineWriter, data: []const u8) !void { var writer = self.inner; var col = self.col; const len = 64 - col; var remain = data; if (remain.len > len) { col = 0; try writer.writeAll(data[0..len]); try writer.writeByte('\n'); remain = data[len..]; } while (remain.len > 64) { try writer.writeAll(remain[0..64]); try writer.writeByte('\n'); remain = data[len..]; } try writer.writeAll(remain); self.col = col + remain.len; } }; // TODO: on BSD / Linux, we could just read the PEM file directly. // This whole rescan + decode is really just needed for MacOS. On Linux // bundle.rescan does find the .pem file(s) which could be in a few different // places, so it's still useful, just not efficient. fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { var bundle: std.crypto.Certificate.Bundle = .{}; try bundle.rescan(allocator); defer bundle.deinit(allocator); const bytes = bundle.bytes.items; if (bytes.len == 0) { lp.log.warn(.app, "No system certificates", .{}); return .{ .len = 0, .flags = 0, .data = bytes.ptr, }; } const encoder = std.base64.standard.Encoder; var arr: std.ArrayList(u8) = .empty; const encoded_size = encoder.calcSize(bytes.len); const buffer_size = encoded_size + (bundle.map.count() * 75) + // start / end per certificate + extra, just in case (encoded_size / 64) // newline per 64 characters ; try arr.ensureTotalCapacity(allocator, buffer_size); errdefer arr.deinit(allocator); var writer = arr.writer(allocator); var it = bundle.map.valueIterator(); while (it.next()) |index| { const cert = try std.crypto.Certificate.der.Element.parse(bytes, index.*); try writer.writeAll("-----BEGIN CERTIFICATE-----\n"); var line_writer = LineWriter{ .inner = writer }; try encoder.encodeWriter(&line_writer, bytes[index.*..cert.slice.end]); try writer.writeAll("\n-----END CERTIFICATE-----\n"); } // Final encoding should not be larger than our initial size estimate lp.assert(buffer_size > arr.items.len, "Http loadCerts", .{ .estimate = buffer_size, .len = arr.items.len }); // Allocate exactly the size needed and copy the data const result = try allocator.dupe(u8, arr.items); // Free the original oversized allocation arr.deinit(allocator); return .{ .len = result.len, .data = result.ptr, .flags = 0, }; } ================================================ FILE: src/network/WebBotAuth.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const crypto = @import("../crypto.zig"); const Http = @import("../network/http.zig"); const WebBotAuth = @This(); pkey: *crypto.EVP_PKEY, keyid: []const u8, directory_url: [:0]const u8, pub const Config = struct { key_file: []const u8, keyid: []const u8, domain: []const u8, }; fn parsePemPrivateKey(pem: []const u8) !*crypto.EVP_PKEY { const begin = "-----BEGIN PRIVATE KEY-----"; const end = "-----END PRIVATE KEY-----"; const start_idx = std.mem.indexOf(u8, pem, begin) orelse return error.InvalidPem; const end_idx = std.mem.indexOf(u8, pem, end) orelse return error.InvalidPem; const b64 = std.mem.trim(u8, pem[start_idx + begin.len .. end_idx], &std.ascii.whitespace); // decode base64 into 48-byte DER buffer var der: [48]u8 = undefined; try std.base64.standard.Decoder.decode(der[0..48], b64); // Ed25519 PKCS#8 structure always places the 32-byte raw private key at offset 16. const key_bytes = der[16..48]; const pkey = crypto.EVP_PKEY_new_raw_private_key(crypto.EVP_PKEY_ED25519, null, key_bytes.ptr, 32); return pkey orelse error.InvalidKey; } fn signEd25519(pkey: *crypto.EVP_PKEY, message: []const u8, out: *[64]u8) !void { const ctx = crypto.EVP_MD_CTX_new() orelse return error.OutOfMemory; defer crypto.EVP_MD_CTX_free(ctx); if (crypto.EVP_DigestSignInit(ctx, null, null, null, pkey) != 1) return error.SignInit; var sig_len: usize = 64; if (crypto.EVP_DigestSign(ctx, out.ptr, &sig_len, message.ptr, message.len) != 1) return error.SignFailed; } pub fn fromConfig(allocator: std.mem.Allocator, config: *const Config) !WebBotAuth { const pem = try std.fs.cwd().readFileAlloc(allocator, config.key_file, 1024 * 4); defer allocator.free(pem); const pkey = try parsePemPrivateKey(pem); errdefer crypto.EVP_PKEY_free(pkey); const directory_url = try std.fmt.allocPrintSentinel( allocator, "https://{s}/.well-known/http-message-signatures-directory", .{config.domain}, 0, ); errdefer allocator.free(directory_url); return .{ .pkey = pkey, // Owned by the Config so it's okay. .keyid = config.keyid, .directory_url = directory_url, }; } pub fn signRequest( self: *const WebBotAuth, allocator: std.mem.Allocator, headers: *Http.Headers, authority: []const u8, ) !void { const now = std.time.timestamp(); const expires = now + 60; // build the signature-input value (without the sig1= label) const sig_input_value = try std.fmt.allocPrint( allocator, "(\"@authority\" \"signature-agent\");created={d};expires={d};keyid=\"{s}\";alg=\"ed25519\";tag=\"web-bot-auth\"", .{ now, expires, self.keyid }, ); defer allocator.free(sig_input_value); // build the canonical string to sign const canonical = try std.fmt.allocPrint( allocator, "\"@authority\": {s}\n\"signature-agent\": \"{s}\"\n\"@signature-params\": {s}", .{ authority, self.directory_url, sig_input_value }, ); defer allocator.free(canonical); // sign it var sig: [64]u8 = undefined; try signEd25519(self.pkey, canonical, &sig); // base64 encode const encoded_len = std.base64.standard.Encoder.calcSize(sig.len); const encoded = try allocator.alloc(u8, encoded_len); defer allocator.free(encoded); _ = std.base64.standard.Encoder.encode(encoded, &sig); // build the 3 headers and add them const sig_agent = try std.fmt.allocPrintSentinel( allocator, "Signature-Agent: \"{s}\"", .{self.directory_url}, 0, ); defer allocator.free(sig_agent); const sig_input = try std.fmt.allocPrintSentinel( allocator, "Signature-Input: sig1={s}", .{sig_input_value}, 0, ); defer allocator.free(sig_input); const signature = try std.fmt.allocPrintSentinel( allocator, "Signature: sig1=:{s}:", .{encoded}, 0, ); defer allocator.free(signature); try headers.add(sig_agent); try headers.add(sig_input); try headers.add(signature); } pub fn deinit(self: WebBotAuth, allocator: std.mem.Allocator) void { crypto.EVP_PKEY_free(self.pkey); allocator.free(self.directory_url); } test "parsePemPrivateKey: valid Ed25519 PKCS#8 PEM" { const pem = \\-----BEGIN PRIVATE KEY----- \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT \\-----END PRIVATE KEY----- \\ ; const pkey = try parsePemPrivateKey(pem); defer crypto.EVP_PKEY_free(pkey); } test "parsePemPrivateKey: missing BEGIN marker returns error" { const bad_pem = "-----END PRIVATE KEY-----\n"; try std.testing.expectError(error.InvalidPem, parsePemPrivateKey(bad_pem)); } test "parsePemPrivateKey: missing END marker returns error" { const bad_pem = "-----BEGIN PRIVATE KEY-----\nMC4CAQA=\n"; try std.testing.expectError(error.InvalidPem, parsePemPrivateKey(bad_pem)); } test "signEd25519: signature length is always 64 bytes" { const pem = \\-----BEGIN PRIVATE KEY----- \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT \\-----END PRIVATE KEY----- \\ ; const pkey = try parsePemPrivateKey(pem); defer crypto.EVP_PKEY_free(pkey); var sig: [64]u8 = @splat(0); try signEd25519(pkey, "hello world", &sig); var all_zero = true; for (sig) |b| if (b != 0) { all_zero = false; break; }; try std.testing.expect(!all_zero); } test "signEd25519: same key + message produces same signature (deterministic)" { const pem = \\-----BEGIN PRIVATE KEY----- \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT \\-----END PRIVATE KEY----- \\ ; const pkey = try parsePemPrivateKey(pem); defer crypto.EVP_PKEY_free(pkey); var sig1: [64]u8 = undefined; var sig2: [64]u8 = undefined; try signEd25519(pkey, "deterministic test", &sig1); try signEd25519(pkey, "deterministic test", &sig2); try std.testing.expectEqualSlices(u8, &sig1, &sig2); } test "signEd25519: same key + diff message produces different signature (deterministic)" { const pem = \\-----BEGIN PRIVATE KEY----- \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT \\-----END PRIVATE KEY----- \\ ; const pkey = try parsePemPrivateKey(pem); defer crypto.EVP_PKEY_free(pkey); var sig1: [64]u8 = undefined; var sig2: [64]u8 = undefined; try signEd25519(pkey, "msg 1", &sig1); try signEd25519(pkey, "msg 2", &sig2); try std.testing.expect(!std.mem.eql(u8, &sig1, &sig2)); } test "signRequest: adds headers with correct names" { const allocator = std.testing.allocator; const pem = \\-----BEGIN PRIVATE KEY----- \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT \\-----END PRIVATE KEY----- \\ ; const pkey = try parsePemPrivateKey(pem); const directory_url = try allocator.dupeZ( u8, "https://example.com/.well-known/http-message-signatures-directory", ); var auth = WebBotAuth{ .pkey = pkey, .keyid = "test-key-id", .directory_url = directory_url, }; defer auth.deinit(allocator); var headers = try Http.Headers.init("User-Agent: Test-Agent"); defer headers.deinit(); try auth.signRequest(allocator, &headers, "example.com"); var it = headers.iterator(); var found_sig_agent = false; var found_sig_input = false; var found_signature = false; var count: usize = 0; while (it.next()) |h| { count += 1; if (std.ascii.eqlIgnoreCase(h.name, "Signature-Agent")) found_sig_agent = true; if (std.ascii.eqlIgnoreCase(h.name, "Signature-Input")) found_sig_input = true; if (std.ascii.eqlIgnoreCase(h.name, "Signature")) found_signature = true; } try std.testing.expect(count >= 3); try std.testing.expect(found_sig_agent); try std.testing.expect(found_sig_input); try std.testing.expect(found_signature); } ================================================ FILE: src/network/http.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const Config = @import("../Config.zig"); const libcurl = @import("../sys/libcurl.zig"); const log = @import("lightpanda").log; const assert = @import("lightpanda").assert; pub const ENABLE_DEBUG = false; const IS_DEBUG = builtin.mode == .Debug; pub const Blob = libcurl.CurlBlob; pub const WaitFd = libcurl.CurlWaitFd; pub const writefunc_error = libcurl.curl_writefunc_error; const Error = libcurl.Error; const ErrorMulti = libcurl.ErrorMulti; const errorFromCode = libcurl.errorFromCode; const errorMFromCode = libcurl.errorMFromCode; const errorCheck = libcurl.errorCheck; const errorMCheck = libcurl.errorMCheck; pub fn curl_version() [*c]const u8 { return libcurl.curl_version(); } pub const Method = enum(u8) { GET = 0, PUT = 1, POST = 2, DELETE = 3, HEAD = 4, OPTIONS = 5, PATCH = 6, PROPFIND = 7, }; pub const Header = struct { name: []const u8, value: []const u8, }; pub const Headers = struct { headers: ?*libcurl.CurlSList, cookies: ?[*c]const u8, pub fn init(user_agent: [:0]const u8) !Headers { const header_list = libcurl.curl_slist_append(null, user_agent); if (header_list == null) { return error.OutOfMemory; } return .{ .headers = header_list, .cookies = null }; } pub fn deinit(self: *const Headers) void { if (self.headers) |hdr| { libcurl.curl_slist_free_all(hdr); } } pub fn add(self: *Headers, header: [*c]const u8) !void { // Copies the value const updated_headers = libcurl.curl_slist_append(self.headers, header); if (updated_headers == null) { return error.OutOfMemory; } self.headers = updated_headers; } fn parseHeader(header_str: []const u8) ?Header { const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); const value = std.mem.trim(u8, header_str[colon_pos + 1 ..], " \t"); return .{ .name = name, .value = value }; } pub fn iterator(self: *Headers) Iterator { return .{ .header = self.headers, .cookies = self.cookies, }; } const Iterator = struct { header: [*c]libcurl.CurlSList, cookies: ?[*c]const u8, pub fn next(self: *Iterator) ?Header { const h = self.header orelse { const cookies = self.cookies orelse return null; self.cookies = null; return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) }; }; self.header = h.*.next; return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); } }; }; // In normal cases, the header iterator comes from the curl linked list. // But it's also possible to inject a response, via `transfer.fulfill`. In that // case, the resposne headers are a list, []const Http.Header. // This union, is an iterator that exposes the same API for either case. pub const HeaderIterator = union(enum) { curl: CurlHeaderIterator, list: ListHeaderIterator, pub fn next(self: *HeaderIterator) ?Header { switch (self.*) { inline else => |*it| return it.next(), } } const CurlHeaderIterator = struct { conn: *const Connection, prev: ?*libcurl.CurlHeader = null, pub fn next(self: *CurlHeaderIterator) ?Header { const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null; self.prev = h; const header = h.*; return .{ .name = std.mem.span(header.name), .value = std.mem.span(header.value), }; } }; const ListHeaderIterator = struct { index: usize = 0, list: []const Header, pub fn next(self: *ListHeaderIterator) ?Header { const idx = self.index; if (idx == self.list.len) { return null; } self.index = idx + 1; return self.list[idx]; } }; }; const HeaderValue = struct { value: []const u8, amount: usize, }; pub const AuthChallenge = struct { status: u16, source: ?enum { server, proxy }, scheme: ?enum { basic, digest }, realm: ?[]const u8, pub fn parse(status: u16, header: []const u8) !AuthChallenge { var ac: AuthChallenge = .{ .status = status, .source = null, .realm = null, .scheme = null, }; const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader; const hname = header[0..sep]; const hvalue = header[sep + 2 ..]; if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) { ac.source = .server; } else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) { ac.source = .proxy; } else { return error.InvalidAuthChallenge; } const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len; const _scheme = hvalue[0..pos]; if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { ac.scheme = .basic; } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { ac.scheme = .digest; } else { return error.UnknownAuthChallengeScheme; } return ac; } }; pub const ResponseHead = struct { pub const MAX_CONTENT_TYPE_LEN = 64; status: u16, url: [*c]const u8, redirect_count: u32, _content_type_len: usize = 0, _content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined, // this is normally an empty list, but if the response is being injected // than it'll be populated. It isn't meant to be used directly, but should // be used through the transfer.responseHeaderIterator() which abstracts // whether the headers are from a live curl easy handle, or injected. _injected_headers: []const Header = &.{}, pub fn contentType(self: *ResponseHead) ?[]u8 { if (self._content_type_len == 0) { return null; } return self._content_type[0..self._content_type_len]; } }; pub const Connection = struct { easy: *libcurl.Curl, node: std.DoublyLinkedList.Node = .{}, pub fn init( ca_blob_: ?libcurl.CurlBlob, config: *const Config, ) !Connection { const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; errdefer libcurl.curl_easy_cleanup(easy); // timeouts try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout()); try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout()); // redirect behavior try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects()); try libcurl.curl_easy_setopt(easy, .follow_location, 2); try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default // proxy const http_proxy = config.httpProxy(); if (http_proxy) |proxy| { try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr); } // tls if (ca_blob_) |ca_blob| { try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob); if (http_proxy != null) { try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob); } } else { assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{}); try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false); try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false); if (http_proxy != null) { try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false); try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false); } } // compression, don't remove this. CloudFront will send gzip content // even if we don't support it, and then it won't be decompressed. // empty string means: use whatever's available try libcurl.curl_easy_setopt(easy, .accept_encoding, ""); // debug if (comptime ENABLE_DEBUG) { try libcurl.curl_easy_setopt(easy, .verbose, true); // Sometimes the default debug output hides some useful data. You can // uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to // get more control over the data (specifically, the `CURLINFO_TEXT` // can include useful data). // try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback); } return .{ .easy = easy, }; } pub fn deinit(self: *const Connection) void { libcurl.curl_easy_cleanup(self.easy); } pub fn setURL(self: *const Connection, url: [:0]const u8) !void { try libcurl.curl_easy_setopt(self.easy, .url, url.ptr); } // a libcurl request has 2 methods. The first is the method that // controls how libcurl behaves. This specifically influences how redirects // are handled. For example, if you do a POST and get a 301, libcurl will // change that to a GET. But if you do a POST and get a 308, libcurl will // keep the POST (and re-send the body). // The second method is the actual string that's included in the request // headers. // These two methods can be different - you can tell curl to behave as though // you made a GET, but include "POST" in the request header. // // Here, we're only concerned about the 2nd method. If we want, we'll set // the first one based on whether or not we have a body. // // It's important that, for each use of this connection, we set the 2nd // method. Else, if we make a HEAD request and re-use the connection, but // DON'T reset this, it'll keep making HEAD requests. // (I don't know if it's as important to reset the 1st method, or if libcurl // can infer that based on the presence of the body, but we also reset it // to be safe); pub fn setMethod(self: *const Connection, method: Method) !void { const easy = self.easy; const m: [:0]const u8 = switch (method) { .GET => "GET", .POST => "POST", .PUT => "PUT", .DELETE => "DELETE", .HEAD => "HEAD", .OPTIONS => "OPTIONS", .PATCH => "PATCH", .PROPFIND => "PROPFIND", }; try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr); } pub fn setBody(self: *const Connection, body: []const u8) !void { const easy = self.easy; try libcurl.curl_easy_setopt(easy, .post, true); try libcurl.curl_easy_setopt(easy, .post_field_size, body.len); try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr); } pub fn setGetMode(self: *const Connection) !void { try libcurl.curl_easy_setopt(self.easy, .http_get, true); } pub fn setHeaders(self: *const Connection, headers: *Headers) !void { try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers); } pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void { try libcurl.curl_easy_setopt(self.easy, .cookie, cookies); } pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void { try libcurl.curl_easy_setopt(self.easy, .private, ptr); } pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); } pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void { try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr); } pub fn setCallbacks( self: *const Connection, comptime header_cb: libcurl.CurlHeaderFunction, comptime data_cb: libcurl.CurlWriteFunction, ) !void { try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy); try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb); try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy); try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb); } pub fn reset(self: *const Connection) !void { try libcurl.curl_easy_setopt(self.easy, .proxy, null); try libcurl.curl_easy_setopt(self.easy, .http_header, null); try libcurl.curl_easy_setopt(self.easy, .header_data, null); try libcurl.curl_easy_setopt(self.easy, .header_function, null); try libcurl.curl_easy_setopt(self.easy, .write_data, null); try libcurl.curl_easy_setopt(self.easy, .write_function, discardBody); } fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize { return count * len; } pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void { try libcurl.curl_easy_setopt(self.easy, .proxy, if (proxy) |p| p.ptr else null); } pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void { try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify); try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify); if (use_proxy) { try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify); try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify); } } pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 { var url: [*c]u8 = undefined; try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url); return url; } pub fn getResponseCode(self: *const Connection) !u16 { var status: c_long = undefined; try libcurl.curl_easy_getinfo(self.easy, .response_code, &status); if (status < 0 or status > std.math.maxInt(u16)) { return 0; } return @intCast(status); } pub fn getRedirectCount(self: *const Connection) !u32 { var count: c_long = undefined; try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count); return @intCast(count); } pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { var hdr: ?*libcurl.CurlHeader = null; libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| { // ErrorHeader includes OutOfMemory — rare but real errors from curl internals. // Logged and returned as null since callers don't expect errors. log.err(.http, "get response header", .{ .name = name, .err = err, }); return null; }; const h = hdr orelse return null; return .{ .amount = h.amount, .value = std.mem.span(h.value), }; } pub fn getPrivate(self: *const Connection) !*anyopaque { var private: *anyopaque = undefined; try libcurl.curl_easy_getinfo(self.easy, .private, &private); return private; } // These are headers that may not be send to the users for inteception. pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void { if (http_headers.proxy_bearer_header) |hdr| { try headers.add(hdr); } } pub fn request(self: *const Connection, http_headers: *const Config.HttpHeaders) !u16 { var header_list = try Headers.init(http_headers.user_agent_header); defer header_list.deinit(); try self.secretHeaders(&header_list, http_headers); try self.setHeaders(&header_list); // Add cookies. if (header_list.cookies) |cookies| { try self.setCookies(cookies); } try libcurl.curl_easy_perform(self.easy); return self.getResponseCode(); } }; pub const Handles = struct { multi: *libcurl.CurlM, pub fn init(config: *const Config) !Handles { const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti; errdefer libcurl.curl_multi_cleanup(multi) catch {}; try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen()); return .{ .multi = multi }; } pub fn deinit(self: *Handles) void { libcurl.curl_multi_cleanup(self.multi) catch {}; } pub fn add(self: *Handles, conn: *const Connection) !void { try libcurl.curl_multi_add_handle(self.multi, conn.easy); } pub fn remove(self: *Handles, conn: *const Connection) !void { try libcurl.curl_multi_remove_handle(self.multi, conn.easy); } pub fn perform(self: *Handles) !c_int { var running: c_int = undefined; try libcurl.curl_multi_perform(self.multi, &running); return running; } pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void { try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null); } pub const MultiMessage = struct { conn: Connection, err: ?Error, }; pub fn readMessage(self: *Handles) ?MultiMessage { var messages_count: c_int = 0; const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; return switch (msg.data) { .done => |err| .{ .conn = .{ .easy = msg.easy_handle }, .err = err, }, else => unreachable, }; } }; fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int { const data = raw[0..len]; switch (msg_type) { .text => std.debug.print("libcurl [text]: {s}\n", .{data}), .header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}), .header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}), // .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}), else => std.debug.print("libcurl ?? {d}\n", .{msg_type}), } return 0; } ================================================ FILE: src/network/websocket.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const log = @import("lightpanda").log; const assert = @import("lightpanda").assert; const CDP_MAX_MESSAGE_SIZE = @import("../Config.zig").CDP_MAX_MESSAGE_SIZE; const Fragments = struct { type: Message.Type, message: std.ArrayList(u8), }; pub const Message = struct { type: Type, data: []const u8, cleanup_fragment: bool, pub const Type = enum { text, binary, close, ping, pong, }; }; // These are the only websocket types that we're currently sending const OpCode = enum(u8) { text = 128 | 1, close = 128 | 8, pong = 128 | 10, }; // WebSocket message reader. Given websocket message, acts as an iterator that // can return zero or more Messages. When next returns null, any incomplete // message will remain in reader.data pub fn Reader(comptime EXPECT_MASK: bool) type { return struct { allocator: Allocator, // position in buf of the start of the next message pos: usize = 0, // position in buf up until where we have valid data // (any new reads must be placed after this) len: usize = 0, // we add 140 to allow 1 control message (ping/pong/close) to be // fragmented into a normal message. buf: []u8, fragments: ?Fragments = null, const Self = @This(); pub fn init(allocator: Allocator) !Self { const buf = try allocator.alloc(u8, 16 * 1024); return .{ .buf = buf, .allocator = allocator, }; } pub fn deinit(self: *Self) void { self.cleanup(); self.allocator.free(self.buf); } pub fn cleanup(self: *Self) void { if (self.fragments) |*f| { f.message.deinit(self.allocator); self.fragments = null; } } pub fn readBuf(self: *Self) []u8 { // We might have read a partial http or websocket message. // Subsequent reads must read from where we left off. return self.buf[self.len..]; } pub fn next(self: *Self) !?Message { LOOP: while (true) { var buf = self.buf[self.pos..self.len]; const length_of_len, const message_len = extractLengths(buf) orelse { // we don't have enough bytes return null; }; const byte1 = buf[0]; if (byte1 & 112 != 0) { return error.ReservedFlags; } if (comptime EXPECT_MASK) { if (buf[1] & 128 != 128) { // client -> server messages _must_ be masked return error.NotMasked; } } else if (buf[1] & 128 != 0) { // server -> client are never masked return error.Masked; } var is_control = false; var is_continuation = false; var message_type: Message.Type = undefined; switch (byte1 & 15) { 0 => is_continuation = true, 1 => message_type = .text, 2 => message_type = .binary, 8 => { is_control = true; message_type = .close; }, 9 => { is_control = true; message_type = .ping; }, 10 => { is_control = true; message_type = .pong; }, else => return error.InvalidMessageType, } if (is_control) { if (message_len > 125) { return error.ControlTooLarge; } } else if (message_len > CDP_MAX_MESSAGE_SIZE) { return error.TooLarge; } else if (message_len > self.buf.len) { const len = self.buf.len; self.buf = try growBuffer(self.allocator, self.buf, message_len); buf = self.buf[0..len]; // we need more data return null; } else if (buf.len < message_len) { // we need more data return null; } // prefix + length_of_len + mask const header_len = 2 + length_of_len + if (comptime EXPECT_MASK) 4 else 0; const payload = buf[header_len..message_len]; if (comptime EXPECT_MASK) { mask(buf[header_len - 4 .. header_len], payload); } // whatever happens after this, we know where the next message starts self.pos += message_len; const fin = byte1 & 128 == 128; if (is_continuation) { const fragments = &(self.fragments orelse return error.InvalidContinuation); if (fragments.message.items.len + message_len > CDP_MAX_MESSAGE_SIZE) { return error.TooLarge; } try fragments.message.appendSlice(self.allocator, payload); if (fin == false) { // maybe we have more parts of the message waiting continue :LOOP; } // this continuation is done! return .{ .type = fragments.type, .data = fragments.message.items, .cleanup_fragment = true, }; } const can_be_fragmented = message_type == .text or message_type == .binary; if (self.fragments != null and can_be_fragmented) { // if this isn't a continuation, then we can't have fragments return error.NestedFragementation; } if (fin == false) { if (can_be_fragmented == false) { return error.InvalidContinuation; } // not continuation, and not fin. It has to be the first message // in a fragmented message. var fragments = Fragments{ .message = .{}, .type = message_type }; try fragments.message.appendSlice(self.allocator, payload); self.fragments = fragments; continue :LOOP; } return .{ .data = payload, .type = message_type, .cleanup_fragment = false, }; } } fn extractLengths(buf: []const u8) ?struct { usize, usize } { if (buf.len < 2) { return null; } const length_of_len: usize = switch (buf[1] & 127) { 126 => 2, 127 => 8, else => 0, }; if (buf.len < length_of_len + 2) { // we definitely don't have enough buf yet return null; } const message_len = switch (length_of_len) { 2 => @as(u16, @intCast(buf[3])) | @as(u16, @intCast(buf[2])) << 8, 8 => @as(u64, @intCast(buf[9])) | @as(u64, @intCast(buf[8])) << 8 | @as(u64, @intCast(buf[7])) << 16 | @as(u64, @intCast(buf[6])) << 24 | @as(u64, @intCast(buf[5])) << 32 | @as(u64, @intCast(buf[4])) << 40 | @as(u64, @intCast(buf[3])) << 48 | @as(u64, @intCast(buf[2])) << 56, else => buf[1] & 127, } + length_of_len + 2 + if (comptime EXPECT_MASK) 4 else 0; // +2 for header prefix, +4 for mask; return .{ length_of_len, message_len }; } // This is called after we've processed complete websocket messages (this // only applies to websocket messages). // There are three cases: // 1 - We don't have any incomplete data (for a subsequent message) in buf. // This is the easier to handle, we can set pos & len to 0. // 2 - We have part of the next message, but we know it'll fit in the // remaining buf. We don't need to do anything // 3 - We have part of the next message, but either it won't fight into the // remaining buffer, or we don't know (because we don't have enough // of the header to tell the length). We need to "compact" the buffer fn compact(self: *Self) void { const pos = self.pos; const len = self.len; assert(pos <= len, "Client.Reader.compact precondition", .{ .pos = pos, .len = len }); // how many (if any) partial bytes do we have const partial_bytes = len - pos; if (partial_bytes == 0) { // We have no partial bytes. Setting these to 0 ensures that we // get the best utilization of our buffer self.pos = 0; self.len = 0; return; } const partial = self.buf[pos..len]; // If we have enough bytes of the next message to tell its length // we'll be able to figure out whether we need to do anything or not. if (extractLengths(partial)) |length_meta| { const next_message_len = length_meta.@"1"; // if this isn't true, then we have a full message and it // should have been processed. assert(pos <= len, "Client.Reader.compact postcondition", .{ .next_len = next_message_len, .partial = partial_bytes }); const missing_bytes = next_message_len - partial_bytes; const free_space = self.buf.len - len; if (missing_bytes < free_space) { // we have enough space in our buffer, as is, return; } } // We're here because we either don't have enough bytes of the next // message, or we know that it won't fit in our buffer as-is. std.mem.copyForwards(u8, self.buf, partial); self.pos = 0; self.len = partial_bytes; } }; } pub const WsConnection = struct { // CLOSE, 2 length, code const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001 const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 // "private-use" close codes must be from 4000-49999 const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000 socket: posix.socket_t, socket_flags: usize, reader: Reader(true), send_arena: ArenaAllocator, json_version_response: []const u8, timeout_ms: u32, pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8, timeout_ms: u32) !WsConnection { const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0); const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true })); assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{}); var reader = try Reader(true).init(allocator); errdefer reader.deinit(); return .{ .socket = socket, .socket_flags = socket_flags, .reader = reader, .send_arena = ArenaAllocator.init(allocator), .json_version_response = json_version_response, .timeout_ms = timeout_ms, }; } pub fn deinit(self: *WsConnection) void { self.reader.deinit(); self.send_arena.deinit(); } pub fn send(self: *WsConnection, data: []const u8) !void { var pos: usize = 0; var changed_to_blocking: bool = false; defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 }); defer if (changed_to_blocking) { // We had to change our socket to blocking me to get our write out // We need to change it back to non-blocking. _ = posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| { log.err(.app, "ws restore nonblocking", .{ .err = err }); }; }; LOOP: while (pos < data.len) { const written = posix.write(self.socket, data[pos..]) catch |err| switch (err) { error.WouldBlock => { // self.socket is nonblocking, because we don't want to block // reads. But our life is a lot easier if we block writes, // largely, because we don't have to maintain a queue of pending // writes (which would each need their own allocations). So // if we get a WouldBlock error, we'll switch the socket to // blocking and switch it back to non-blocking after the write // is complete. Doesn't seem particularly efficiently, but // this should virtually never happen. assert(changed_to_blocking == false, "WsConnection.double block", .{}); changed_to_blocking = true; _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))); continue :LOOP; }, else => return err, }; if (written == 0) { return error.Closed; } pos += written; } } const EMPTY_PONG = [_]u8{ 138, 0 }; fn sendPong(self: *WsConnection, data: []const u8) !void { if (data.len == 0) { return self.send(&EMPTY_PONG); } var header_buf: [10]u8 = undefined; const header = websocketHeader(&header_buf, .pong, data.len); const allocator = self.send_arena.allocator(); const framed = try allocator.alloc(u8, header.len + data.len); @memcpy(framed[0..header.len], header); @memcpy(framed[header.len..], data); return self.send(framed); } // called by CDP // Websocket frames have a variable length header. For server-client, // it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have // writev, so we need to get creative. We'll JSON serialize to a // buffer, where the first 10 bytes are reserved. We can then backfill // the header and send the slice. pub fn sendJSON(self: *WsConnection, message: anytype, opts: std.json.Stringify.Options) !void { const allocator = self.send_arena.allocator(); var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512); // reserve space for the maximum possible header try aw.writer.writeAll(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }); try std.json.Stringify.value(message, opts, &aw.writer); const framed = fillWebsocketHeader(aw.toArrayList()); return self.send(framed); } pub fn sendJSONRaw( self: *WsConnection, buf: std.ArrayList(u8), ) !void { // Dangerous API!. We assume the caller has reserved the first 10 // bytes in `buf`. const framed = fillWebsocketHeader(buf); return self.send(framed); } pub fn read(self: *WsConnection) !usize { const n = try posix.read(self.socket, self.reader.readBuf()); self.reader.len += n; return n; } pub fn processMessages(self: *WsConnection, handler: anytype) !bool { var reader = &self.reader; while (true) { const msg = reader.next() catch |err| { switch (err) { error.TooLarge => self.send(&CLOSE_TOO_BIG) catch {}, error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, error.ControlTooLarge => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, error.NestedFragementation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, error.OutOfMemory => {}, // don't borther trying to send an error in this case } return err; } orelse break; switch (msg.type) { .pong => {}, .ping => try self.sendPong(msg.data), .close => { self.send(&CLOSE_NORMAL) catch {}; return false; }, .text, .binary => if (handler.handleMessage(msg.data) == false) { return false; }, } if (msg.cleanup_fragment) { reader.cleanup(); } } // We might have read part of the next message. Our reader potentially // has to move data around in its buffer to make space. reader.compact(); return true; } pub fn upgrade(self: *WsConnection, request: []u8) !void { // our caller already confirmed that we have a trailing \r\n\r\n const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable; const request_line = request[0..request_line_end]; if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) { return error.InvalidProtocol; } // we need to extract the sec-websocket-key value var key: []const u8 = ""; // we need to make sure that we got all the necessary headers + values var required_headers: u8 = 0; // can't std.mem.split because it forces the iterated value to be const // (we could @constCast...) var buf = request[request_line_end + 2 ..]; while (buf.len > 4) { const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable; const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest; const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace); const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace); if (std.mem.eql(u8, name, "upgrade")) { if (!std.ascii.eqlIgnoreCase("websocket", value)) { return error.InvalidUpgradeHeader; } required_headers |= 1; } else if (std.mem.eql(u8, name, "sec-websocket-version")) { if (value.len != 2 or value[0] != '1' or value[1] != '3') { return error.InvalidVersionHeader; } required_headers |= 2; } else if (std.mem.eql(u8, name, "connection")) { // find if connection header has upgrade in it, example header: // Connection: keep-alive, Upgrade if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) { return error.InvalidConnectionHeader; } required_headers |= 4; } else if (std.mem.eql(u8, name, "sec-websocket-key")) { key = value; required_headers |= 8; } const next = index + 2; buf = buf[next..]; } if (required_headers != 15) { return error.MissingHeaders; } // our caller has already made sure this request ended in \r\n\r\n // so it isn't something we need to check again const alloc = self.send_arena.allocator(); const response = blk: { // Response to an ugprade request is always this, with // the Sec-Websocket-Accept value a spacial sha1 hash of the // request "sec-websocket-version" and a magic value. const template = "HTTP/1.1 101 Switching Protocols\r\n" ++ "Upgrade: websocket\r\n" ++ "Connection: upgrade\r\n" ++ "Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n"; // The response will be sent via the IO Loop and thus has to have its // own lifetime. const res = try alloc.dupe(u8, template); // magic response const key_pos = res.len - 32; var h: [20]u8 = undefined; var hasher = std.crypto.hash.Sha1.init(.{}); hasher.update(key); // websocket spec always used this value hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); hasher.final(&h); _ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]); break :blk res; }; return self.send(response); } pub fn sendHttpError(self: *WsConnection, comptime status: u16, comptime body: []const u8) void { const response = std.fmt.comptimePrint( "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", .{ status, body.len, body }, ); // we're going to close this connection anyways, swallowing any // error seems safe self.send(response) catch {}; } pub fn getAddress(self: *WsConnection) !std.net.Address { var address: std.net.Address = undefined; var socklen: posix.socklen_t = @sizeOf(std.net.Address); try posix.getpeername(self.socket, &address.any, &socklen); return address; } pub fn sendClose(self: *WsConnection) void { self.send(&CLOSE_GOING_AWAY) catch {}; } pub fn shutdown(self: *WsConnection) void { posix.shutdown(self.socket, .recv) catch {}; } pub fn setBlocking(self: *WsConnection, blocking: bool) !void { if (blocking) { _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))); } else { _ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags); } } }; fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 { // can't use buf[0..10] here, because the header length // is variable. If it's just 2 bytes, for example, we need the // framed message to be: // h1, h2, data // If we use buf[0..10], we'd get: // h1, h2, 0, 0, 0, 0, 0, 0, 0, 0, data var header_buf: [10]u8 = undefined; // -10 because we reserved 10 bytes for the header above const header = websocketHeader(&header_buf, .text, buf.items.len - 10); const start = 10 - header.len; const message = buf.items; @memcpy(message[start..10], header); return message[start..]; } // makes the assumption that our caller reserved the first // 10 bytes for the header fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 { assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len }); const len = payload_len; buf[0] = 128 | @intFromEnum(op_code); // fin | opcode if (len <= 125) { buf[1] = @intCast(len); return buf[0..2]; } if (len < 65536) { buf[1] = 126; buf[2] = @intCast((len >> 8) & 0xFF); buf[3] = @intCast(len & 0xFF); return buf[0..4]; } buf[1] = 127; buf[2] = 0; buf[3] = 0; buf[4] = 0; buf[5] = 0; buf[6] = @intCast((len >> 24) & 0xFF); buf[7] = @intCast((len >> 16) & 0xFF); buf[8] = @intCast((len >> 8) & 0xFF); buf[9] = @intCast(len & 0xFF); return buf[0..10]; } fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 { // from std.ArrayList var new_capacity = buf.len; while (true) { new_capacity +|= new_capacity / 2 + 8; if (new_capacity >= required_capacity) break; } log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity }); if (allocator.resize(buf, new_capacity)) { return buf.ptr[0..new_capacity]; } const new_buffer = try allocator.alloc(u8, new_capacity); @memcpy(new_buffer[0..buf.len], buf); allocator.free(buf); return new_buffer; } // In-place string lowercase fn toLower(str: []u8) []u8 { for (str, 0..) |ch, i| { str[i] = std.ascii.toLower(ch); } return str; } // Used when SIMD isn't available, or for any remaining part of the message // which is too small to effectively use SIMD. fn simpleMask(m: []const u8, payload: []u8) void { for (payload, 0..) |b, i| { payload[i] = b ^ m[i & 3]; } } // Zig is in a weird backend transition right now. Need to determine if // SIMD is even available. const backend_supports_vectors = switch (builtin.zig_backend) { .stage2_llvm, .stage2_c => true, else => false, }; // Websocket messages from client->server are masked using a 4 byte XOR mask fn mask(m: []const u8, payload: []u8) void { var data = payload; if (!comptime backend_supports_vectors) return simpleMask(m, data); const vector_size = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize); if (data.len >= vector_size) { const mask_vector = std.simd.repeat(vector_size, @as(@Vector(4, u8), m[0..4].*)); while (data.len >= vector_size) { const slice = data[0..vector_size]; const masked_data_slice: @Vector(vector_size, u8) = slice.*; slice.* = masked_data_slice ^ mask_vector; data = data[vector_size..]; } } simpleMask(m, data); } const testing = std.testing; test "mask" { var buf: [4000]u8 = undefined; const messages = [_][]const u8{ "1234", "1234" ** 99, "1234" ** 999 }; for (messages) |message| { // we need the message to be mutable since mask operates in-place const payload = buf[0..message.len]; @memcpy(payload, message); mask(&.{ 1, 2, 200, 240 }, payload); try testing.expectEqual(false, std.mem.eql(u8, payload, message)); mask(&.{ 1, 2, 200, 240 }, payload); try testing.expectEqual(true, std.mem.eql(u8, payload, message)); } } ================================================ FILE: src/slab.zig ================================================ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Alignment = std.mem.Alignment; const Slab = struct { alignment: Alignment, item_size: usize, max_slot_count: usize, bitset: std.bit_set.DynamicBitSetUnmanaged, chunks: std.ArrayList([]u8), pub fn init( allocator: Allocator, alignment: Alignment, item_size: usize, max_slot_count: usize, ) !Slab { return .{ .alignment = alignment, .item_size = item_size, .bitset = try .initFull(allocator, 0), .chunks = .empty, .max_slot_count = max_slot_count, }; } pub fn deinit(self: *Slab, allocator: Allocator) void { self.bitset.deinit(allocator); for (self.chunks.items) |chunk| { allocator.rawFree(chunk, self.alignment, @returnAddress()); } self.chunks.deinit(allocator); } inline fn calculateChunkSize(self: *Slab, chunk_index: usize) usize { const safe_index: u6 = @intCast(@min(std.math.maxInt(u6), chunk_index)); const exponential = @as(usize, 1) << safe_index; return @min(exponential, self.max_slot_count); } inline fn toBitsetIndex(self: *Slab, chunk_index: usize, slot_index: usize) usize { var offset: usize = 0; for (0..chunk_index) |i| { const chunk_size = self.calculateChunkSize(i); offset += chunk_size; } return offset + slot_index; } inline fn toChunkAndSlotIndices(self: *Slab, bitset_index: usize) struct { usize, usize } { var offset: usize = 0; var chunk_index: usize = 0; while (chunk_index < self.chunks.items.len) : (chunk_index += 1) { const chunk_size = self.calculateChunkSize(chunk_index); if (bitset_index < offset + chunk_size) { return .{ chunk_index, bitset_index - offset }; } offset += chunk_size; } unreachable; } fn alloc(self: *Slab, allocator: Allocator) ![]u8 { if (self.bitset.findFirstSet()) |index| { const chunk_index, const slot_index = self.toChunkAndSlotIndices(index); // if we have a free slot self.bitset.unset(index); const chunk = self.chunks.items[chunk_index]; const offset = slot_index * self.item_size; return chunk.ptr[offset..][0..self.item_size]; } else { const old_capacity = self.bitset.bit_length; // if we have don't have a free slot try self.allocateChunk(allocator); const first_slot_index = old_capacity; self.bitset.unset(first_slot_index); const new_chunk = self.chunks.items[self.chunks.items.len - 1]; return new_chunk.ptr[0..self.item_size]; } } fn free(self: *Slab, ptr: [*]u8) void { const addr = @intFromPtr(ptr); for (self.chunks.items, 0..) |chunk, i| { const chunk_start = @intFromPtr(chunk.ptr); const chunk_end = chunk_start + chunk.len; if (addr >= chunk_start and addr < chunk_end) { const offset = addr - chunk_start; const slot_index = offset / self.item_size; const bitset_index = self.toBitsetIndex(i, slot_index); assert(!self.bitset.isSet(bitset_index)); self.bitset.set(bitset_index); return; } } unreachable; } fn allocateChunk(self: *Slab, allocator: Allocator) !void { const next_chunk_size = self.calculateChunkSize(self.chunks.items.len); const chunk_len = self.item_size * next_chunk_size; const chunk_ptr = allocator.rawAlloc( chunk_len, self.alignment, @returnAddress(), ) orelse return error.FailedChildAllocation; const chunk = chunk_ptr[0..chunk_len]; try self.chunks.append(allocator, chunk); const new_capacity = self.bitset.bit_length + next_chunk_size; try self.bitset.resize(allocator, new_capacity, true); } const Stats = struct { key: SlabKey, item_size: usize, chunk_count: usize, total_slots: usize, slots_in_use: usize, slots_free: usize, bytes_allocated: usize, bytes_in_use: usize, bytes_free: usize, utilization_ratio: f64, }; fn getStats(self: *const Slab, key: SlabKey) Stats { const total_slots = self.bitset.bit_length; const free_slots = self.bitset.count(); const used_slots = total_slots - free_slots; const bytes_allocated = total_slots * self.item_size; const bytes_in_use = used_slots * self.item_size; const utilization_ratio = if (bytes_allocated > 0) @as(f64, @floatFromInt(bytes_in_use)) / @as(f64, @floatFromInt(bytes_allocated)) else 0.0; return .{ .key = key, .item_size = self.item_size, .chunk_count = self.chunks.items.len, .total_slots = total_slots, .slots_in_use = used_slots, .slots_free = free_slots, .bytes_allocated = bytes_allocated, .bytes_in_use = bytes_in_use, .bytes_free = free_slots * self.item_size, .utilization_ratio = utilization_ratio, }; } }; const SlabKey = struct { size: usize, alignment: Alignment, }; pub const SlabAllocator = struct { const Self = @This(); child_allocator: Allocator, max_slot_count: usize, slabs: std.ArrayHashMapUnmanaged(SlabKey, Slab, struct { const Context = @This(); pub fn hash(_: Context, key: SlabKey) u32 { var hasher = std.hash.Wyhash.init(0); std.hash.autoHash(&hasher, key.size); std.hash.autoHash(&hasher, key.alignment); return @truncate(hasher.final()); } pub fn eql(_: Context, a: SlabKey, b: SlabKey, _: usize) bool { return a.size == b.size and a.alignment == b.alignment; } }, false) = .empty, pub fn init(child_allocator: Allocator, max_slot_count: usize) Self { assert(std.math.isPowerOfTwo(max_slot_count)); return .{ .child_allocator = child_allocator, .slabs = .empty, .max_slot_count = max_slot_count, }; } pub fn deinit(self: *Self) void { for (self.slabs.values()) |*slab| { slab.deinit(self.child_allocator); } self.slabs.deinit(self.child_allocator); } pub const ResetKind = enum { /// Free all chunks and release all memory. clear, /// Keep all chunks, reset trees to reuse memory. retain_capacity, }; /// This clears all of the stored memory, freeing the currently used chunks. pub fn reset(self: *Self, kind: ResetKind) void { switch (kind) { .clear => { for (self.slabs.values()) |*slab| { for (slab.chunks.items) |chunk| { self.child_allocator.free(chunk); } slab.chunks.clearAndFree(self.child_allocator); slab.bitset.deinit(self.child_allocator); } self.slabs.clearAndFree(self.child_allocator); }, .retain_capacity => { for (self.slabs.values()) |*slab| { slab.bitset.setAll(); } }, } } const Stats = struct { total_allocated_bytes: usize, bytes_in_use: usize, bytes_free: usize, slab_count: usize, total_chunks: usize, total_slots: usize, slots_in_use: usize, slots_free: usize, fragmentation_ratio: f64, utilization_ratio: f64, slabs: []const Slab.Stats, pub fn print(self: *const Stats, stream: *std.io.Writer) !void { try stream.print("\n", .{}); try stream.print("\n=== Slab Allocator Statistics ===\n", .{}); try stream.print("Overall Memory:\n", .{}); try stream.print(" Total allocated: {} bytes ({d:.2} MB)\n", .{ self.total_allocated_bytes, @as(f64, @floatFromInt(self.total_allocated_bytes)) / 1_048_576.0, }); try stream.print(" In use: {} bytes ({d:.2} MB)\n", .{ self.bytes_in_use, @as(f64, @floatFromInt(self.bytes_in_use)) / 1_048_576.0, }); try stream.print(" Free: {} bytes ({d:.2} MB)\n", .{ self.bytes_free, @as(f64, @floatFromInt(self.bytes_free)) / 1_048_576.0, }); try stream.print("\nOverall Structure:\n", .{}); try stream.print(" Slab Count: {}\n", .{self.slab_count}); try stream.print(" Total chunks: {}\n", .{self.total_chunks}); try stream.print(" Total slots: {}\n", .{self.total_slots}); try stream.print(" Slots in use: {}\n", .{self.slots_in_use}); try stream.print(" Slots free: {}\n", .{self.slots_free}); try stream.print("\nOverall Efficiency:\n", .{}); try stream.print(" Utilization: {d:.1}%\n", .{self.utilization_ratio * 100.0}); try stream.print(" Fragmentation: {d:.1}%\n", .{self.fragmentation_ratio * 100.0}); if (self.slabs.len > 0) { try stream.print("\nPer-Slab Breakdown:\n", .{}); try stream.print( " {s:>5} | {s:>4} | {s:>6} | {s:>6} | {s:>6} | {s:>10} | {s:>6}\n", .{ "Size", "Algn", "Chunks", "Slots", "InUse", "Bytes", "Util%" }, ); try stream.print( " {s:-<5}-+-{s:-<4}-+-{s:-<6}-+-{s:-<6}-+-{s:-<6}-+-{s:-<10}-+-{s:-<6}\n", .{ "", "", "", "", "", "", "" }, ); for (self.slabs) |slab| { try stream.print(" {d:5} | {d:4} | {d:6} | {d:6} | {d:6} | {d:10} | {d:5.1}%\n", .{ slab.key.size, @intFromEnum(slab.key.alignment), slab.chunk_count, slab.total_slots, slab.slots_in_use, slab.bytes_allocated, slab.utilization_ratio * 100.0, }); } } } }; pub fn getStats(self: *Self, a: std.mem.Allocator) !Stats { var slab_stats: std.ArrayList(Slab.Stats) = try .initCapacity(a, self.slabs.entries.len); errdefer slab_stats.deinit(a); var stats = Stats{ .total_allocated_bytes = 0, .bytes_in_use = 0, .bytes_free = 0, .slab_count = self.slabs.count(), .total_chunks = 0, .total_slots = 0, .slots_in_use = 0, .slots_free = 0, .fragmentation_ratio = 0.0, .utilization_ratio = 0.0, .slabs = &.{}, }; var it = self.slabs.iterator(); while (it.next()) |entry| { const key = entry.key_ptr.*; const slab = entry.value_ptr; const slab_stat = slab.getStats(key); slab_stats.appendAssumeCapacity(slab_stat); stats.total_allocated_bytes += slab_stat.bytes_allocated; stats.bytes_in_use += slab_stat.bytes_in_use; stats.bytes_free += slab_stat.bytes_free; stats.total_chunks += slab_stat.chunk_count; stats.total_slots += slab_stat.total_slots; stats.slots_in_use += slab_stat.slots_in_use; stats.slots_free += slab_stat.slots_free; } if (stats.total_allocated_bytes > 0) { stats.fragmentation_ratio = @as(f64, @floatFromInt(stats.bytes_free)) / @as(f64, @floatFromInt(stats.total_allocated_bytes)); stats.utilization_ratio = @as(f64, @floatFromInt(stats.bytes_in_use)) / @as(f64, @floatFromInt(stats.total_allocated_bytes)); } stats.slabs = try slab_stats.toOwnedSlice(a); return stats; } pub const vtable = Allocator.VTable{ .alloc = alloc, .free = free, .remap = Allocator.noRemap, .resize = Allocator.noResize, }; pub fn allocator(self: *Self) Allocator { return .{ .ptr = self, .vtable = &vtable, }; } fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { const self: *Self = @ptrCast(@alignCast(ctx)); _ = ret_addr; const aligned_len = std.mem.alignForward(usize, len, alignment.toByteUnits()); const list_gop = self.slabs.getOrPut( self.child_allocator, SlabKey{ .size = aligned_len, .alignment = alignment }, ) catch return null; if (!list_gop.found_existing) { list_gop.value_ptr.* = Slab.init( self.child_allocator, alignment, aligned_len, self.max_slot_count, ) catch return null; } const list = list_gop.value_ptr; const buf = list.alloc(self.child_allocator) catch return null; return buf[0..len].ptr; } fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { const self: *Self = @ptrCast(@alignCast(ctx)); _ = ret_addr; const ptr = memory.ptr; const len = memory.len; const aligned_len = std.mem.alignForward(usize, len, alignment.toByteUnits()); const list = self.slabs.getPtr(.{ .size = aligned_len, .alignment = alignment }).?; list.free(ptr); } }; const testing = std.testing; const TestSlabAllocator = SlabAllocator; test "slab allocator - basic allocation and free" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate some memory const ptr1 = try allocator.alloc(u8, 100); try testing.expect(ptr1.len == 100); // Write to it to ensure it's valid @memset(ptr1, 42); try testing.expectEqual(@as(u8, 42), ptr1[50]); // Free it allocator.free(ptr1); } test "slab allocator - multiple allocations" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); const ptr1 = try allocator.alloc(u8, 64); const ptr2 = try allocator.alloc(u8, 128); const ptr3 = try allocator.alloc(u8, 256); // Ensure they don't overlap const addr1 = @intFromPtr(ptr1.ptr); const addr2 = @intFromPtr(ptr2.ptr); const addr3 = @intFromPtr(ptr3.ptr); try testing.expect(addr1 + 64 <= addr2 or addr2 + 128 <= addr1); try testing.expect(addr2 + 128 <= addr3 or addr3 + 256 <= addr2); allocator.free(ptr1); allocator.free(ptr2); allocator.free(ptr3); } test "slab allocator - no coalescing (different size classes)" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate two blocks of same size const ptr1 = try allocator.alloc(u8, 128); const ptr2 = try allocator.alloc(u8, 128); // Free them (no coalescing in slab allocator) allocator.free(ptr1); allocator.free(ptr2); // Can't allocate larger block from these freed 128-byte blocks const ptr3 = try allocator.alloc(u8, 256); // ptr3 will be from a different size class, not coalesced from ptr1+ptr2 const addr1 = @intFromPtr(ptr1.ptr); const addr3 = @intFromPtr(ptr3.ptr); // They should NOT be adjacent (different size classes) try testing.expect(addr3 < addr1 or addr3 >= addr1 + 256); allocator.free(ptr3); } test "slab allocator - reuse freed memory" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); const ptr1 = try allocator.alloc(u8, 64); const addr1 = @intFromPtr(ptr1.ptr); allocator.free(ptr1); // Allocate same size, should reuse from same slab const ptr2 = try allocator.alloc(u8, 64); const addr2 = @intFromPtr(ptr2.ptr); try testing.expectEqual(addr1, addr2); allocator.free(ptr2); } test "slab allocator - multiple size classes" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate various sizes - each creates a new slab var ptrs: [10][]u8 = undefined; const sizes = [_]usize{ 24, 40, 64, 88, 128, 144, 200, 256, 512, 1000 }; for (&ptrs, sizes) |*ptr, size| { ptr.* = try allocator.alloc(u8, size); @memset(ptr.*, 0xFF); } // Should have created multiple slabs try testing.expect(slab_alloc.slabs.count() >= 10); // Free all for (ptrs) |ptr| { allocator.free(ptr); } } test "slab allocator - various sizes" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Test different sizes (not limited to powers of 2!) const sizes = [_]usize{ 8, 16, 24, 32, 40, 64, 88, 128, 144, 256 }; for (sizes) |size| { const ptr = try allocator.alloc(u8, size); try testing.expect(ptr.len == size); @memset(ptr, @intCast(size & 0xFF)); allocator.free(ptr); } } test "slab allocator - exact sizes (no rounding)" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Odd sizes stay exact (unlike buddy which rounds to power of 2) const ptr1 = try allocator.alloc(u8, 100); const ptr2 = try allocator.alloc(u8, 200); const ptr3 = try allocator.alloc(u8, 50); // Exact sizes! try testing.expect(ptr1.len == 100); try testing.expect(ptr2.len == 200); try testing.expect(ptr3.len == 50); allocator.free(ptr1); allocator.free(ptr2); allocator.free(ptr3); } test "slab allocator - chunk allocation" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate many items of same size to force multiple chunks var ptrs: [100][]u8 = undefined; for (&ptrs) |*ptr| { ptr.* = try allocator.alloc(u8, 64); } // Should have allocated multiple chunks (32 items per chunk) const slab = slab_alloc.slabs.getPtr(.{ .size = 64, .alignment = Alignment.@"1" }).?; try testing.expect(slab.chunks.items.len > 1); // Free all for (ptrs) |ptr| { allocator.free(ptr); } } test "slab allocator - reset with retain_capacity" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate some memory const ptr1 = try allocator.alloc(u8, 128); const ptr2 = try allocator.alloc(u8, 256); _ = ptr1; _ = ptr2; const slabs_before = slab_alloc.slabs.count(); const slab_128 = slab_alloc.slabs.getPtr(.{ .size = 128, .alignment = Alignment.@"1" }).?; const chunks_before = slab_128.chunks.items.len; // Reset but keep chunks slab_alloc.reset(.retain_capacity); try testing.expectEqual(slabs_before, slab_alloc.slabs.count()); try testing.expectEqual(chunks_before, slab_128.chunks.items.len); // Should be able to allocate again const ptr3 = try allocator.alloc(u8, 512); allocator.free(ptr3); } test "slab allocator - reset with clear" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate some memory const ptr1 = try allocator.alloc(u8, 128); _ = ptr1; try testing.expect(slab_alloc.slabs.count() > 0); // Reset and free everything slab_alloc.reset(.clear); try testing.expectEqual(@as(usize, 0), slab_alloc.slabs.count()); // Should still work after reset const ptr2 = try allocator.alloc(u8, 256); allocator.free(ptr2); } test "slab allocator - stress test" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); var prng = std.Random.DefaultPrng.init(0); const random = prng.random(); var ptrs: std.ArrayList([]u8) = .empty; defer { for (ptrs.items) |ptr| { allocator.free(ptr); } ptrs.deinit(allocator); } // Random allocations and frees var i: usize = 0; while (i < 100) : (i += 1) { if (random.boolean() and ptrs.items.len > 0) { // Free a random allocation const index = random.uintLessThan(usize, ptrs.items.len); allocator.free(ptrs.swapRemove(index)); } else { // Allocate random size (8 to 512) const size = random.uintAtMost(usize, 504) + 8; const ptr = try allocator.alloc(u8, size); try ptrs.append(allocator, ptr); // Write to ensure it's valid @memset(ptr, @intCast(i & 0xFF)); } } } test "slab allocator - alignment" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); const ptr1 = try allocator.create(u64); const ptr2 = try allocator.create(u32); const ptr3 = try allocator.create([100]u8); allocator.destroy(ptr1); allocator.destroy(ptr2); allocator.destroy(ptr3); } test "slab allocator - no resize support" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); const slice = try allocator.alloc(u8, 100); @memset(slice, 42); // Resize should fail (not supported) try testing.expect(!allocator.resize(slice, 90)); try testing.expect(!allocator.resize(slice, 200)); allocator.free(slice); } test "slab allocator - fragmentation pattern" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate 10 items var items: [10][]u8 = undefined; for (&items) |*item| { item.* = try allocator.alloc(u8, 64); @memset(item.*, 0xFF); } // Free every other one allocator.free(items[0]); allocator.free(items[2]); allocator.free(items[4]); allocator.free(items[6]); allocator.free(items[8]); // Allocate new items - should reuse freed slots const new1 = try allocator.alloc(u8, 64); const new2 = try allocator.alloc(u8, 64); const new3 = try allocator.alloc(u8, 64); // Should get some of the freed slots back const addrs = [_]usize{ @intFromPtr(items[0].ptr), @intFromPtr(items[2].ptr), @intFromPtr(items[4].ptr), @intFromPtr(items[6].ptr), @intFromPtr(items[8].ptr), }; const new1_addr = @intFromPtr(new1.ptr); var found = false; for (addrs) |addr| { if (new1_addr == addr) found = true; } try testing.expect(found); // Cleanup allocator.free(items[1]); allocator.free(items[3]); allocator.free(items[5]); allocator.free(items[7]); allocator.free(items[9]); allocator.free(new1); allocator.free(new2); allocator.free(new3); } test "slab allocator - many small allocations" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate 1000 small items var ptrs: std.ArrayList([]u8) = .empty; defer { for (ptrs.items) |ptr| { allocator.free(ptr); } ptrs.deinit(allocator); } var i: usize = 0; while (i < 1000) : (i += 1) { const ptr = try allocator.alloc(u8, 24); try ptrs.append(allocator, ptr); } // Should have created multiple chunks const slab = slab_alloc.slabs.getPtr(.{ .size = 24, .alignment = Alignment.@"1" }).?; try testing.expect(slab.chunks.items.len > 1); } test "slab allocator - zero waste for exact sizes" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // These sizes have zero internal fragmentation (unlike buddy) const sizes = [_]usize{ 24, 40, 56, 88, 144, 152, 184, 232, 648 }; for (sizes) |size| { const ptr = try allocator.alloc(u8, size); // Exact size returned! try testing.expectEqual(size, ptr.len); @memset(ptr, 0xFF); allocator.free(ptr); } } test "slab allocator - different size classes don't interfere" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Allocate size 64 const ptr_64 = try allocator.alloc(u8, 64); const addr_64 = @intFromPtr(ptr_64.ptr); allocator.free(ptr_64); // Allocate size 128 - should NOT reuse size-64 slot const ptr_128 = try allocator.alloc(u8, 128); const addr_128 = @intFromPtr(ptr_128.ptr); try testing.expect(addr_64 != addr_128); // Allocate size 64 again - SHOULD reuse original slot const ptr_64_again = try allocator.alloc(u8, 64); const addr_64_again = @intFromPtr(ptr_64_again.ptr); try testing.expectEqual(addr_64, addr_64_again); allocator.free(ptr_128); allocator.free(ptr_64_again); } test "slab allocator - 16-byte alignment" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); // Request 16-byte aligned memory const ptr = try allocator.alignedAlloc(u8, .@"16", 152); defer allocator.free(ptr); // Verify alignment const addr = @intFromPtr(ptr.ptr); try testing.expect(addr % 16 == 0); // Make sure we can use it @memset(ptr, 0xFF); } test "slab allocator - various alignments" { var slab_alloc = TestSlabAllocator.init(testing.allocator, 16); defer slab_alloc.deinit(); const allocator = slab_alloc.allocator(); const alignments = [_]std.mem.Alignment{ .@"1", .@"2", .@"4", .@"8", .@"16" }; inline for (alignments) |alignment| { const ptr = try allocator.alignedAlloc(u8, alignment, 100); defer allocator.free(ptr); const addr = @intFromPtr(ptr.ptr); const align_value = alignment.toByteUnits(); try testing.expect(addr % align_value == 0); } } ================================================ FILE: src/string.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Allocator = std.mem.Allocator; const M = @This(); // German-string (small string optimization) pub const String = packed struct { len: i32, payload: packed union { // Zig won't let you put an array in a packed struct/union. But it will // let you put a vector. content: @Vector(12, u8), heap: packed struct { prefix: @Vector(4, u8), ptr: [*]const u8 }, }, const tombstone = -1; pub const empty = String{ .len = 0, .payload = .{ .content = @splat(0) } }; pub const deleted = String{ .len = tombstone, .payload = .{ .content = @splat(0) } }; // for packages that already have String imported, then can use String.Global pub const Global = M.Global; // Wraps an existing string. For strings with len <= 12, this can be done at // comptime: comptime String.wrap("id"); // For strings with len > 12, this must be done at runtime even for a string // literal. This is because, at comptime, we do not have a ptr for data and // thus can't store it. pub fn wrap(input: anytype) String { if (@inComptime()) { const l = input.len; if (l > 12) { @compileError("Comptime string must be <= 12 bytes (SSO only): " ++ input); } var content: [12]u8 = @splat(0); @memcpy(content[0..l], input); return .{ .len = @intCast(l), .payload = .{ .content = content } }; } // Runtime path - handle both String and []const u8 if (@TypeOf(input) == String) { return input; } const l = input.len; if (l <= 12) { var content: [12]u8 = @splat(0); @memcpy(content[0..l], input); return .{ .len = @intCast(l), .payload = .{ .content = content } }; } return .{ .len = @intCast(l), .payload = .{ .heap = .{ .prefix = input[0..4].*, .ptr = input.ptr, } }, }; } pub const InitOpts = struct { dupe: bool = true, }; pub fn init(allocator: Allocator, input: []const u8, opts: InitOpts) !String { if (input.len >= std.math.maxInt(i32)) { return error.StringTooLarge; } const l: u32 = @intCast(input.len); if (l <= 12) { var content: [12]u8 = @splat(0); @memcpy(content[0..l], input); return .{ .len = @intCast(l), .payload = .{ .content = content } }; } return .{ .len = @intCast(l), .payload = .{ .heap = .{ .prefix = input[0..4].*, .ptr = (intern(input) orelse (if (opts.dupe) (try allocator.dupe(u8, input)) else input)).ptr, } }, }; } pub fn deinit(self: *const String, allocator: Allocator) void { const len = self.len; if (len > 12) { allocator.free(self.payload.heap.ptr[0..@intCast(len)]); } } pub fn dupe(self: *const String, allocator: Allocator) !String { return .init(allocator, self.str(), .{ .dupe = true }); } pub fn concat(allocator: Allocator, parts: []const []const u8) !String { var total_len: usize = 0; for (parts) |part| { total_len += part.len; } if (total_len <= 12) { var content: [12]u8 = @splat(0); var pos: usize = 0; for (parts) |part| { @memcpy(content[pos..][0..part.len], part); pos += part.len; } return .{ .len = @intCast(total_len), .payload = .{ .content = content } }; } const result = try allocator.alloc(u8, total_len); var pos: usize = 0; for (parts) |part| { @memcpy(result[pos..][0..part.len], part); pos += part.len; } return .{ .len = @intCast(total_len), .payload = .{ .heap = .{ .prefix = result[0..4].*, .ptr = (intern(result) orelse result).ptr, } }, }; } pub fn str(self: *const String) []const u8 { const l = self.len; if (l < 0) { return ""; } const ul: usize = @intCast(l); if (ul <= 12) { const slice: []const u8 = @ptrCast(self); return slice[4 .. ul + 4]; } return self.payload.heap.ptr[0..ul]; } pub fn isDeleted(self: *const String) bool { return self.len == tombstone; } pub fn format(self: String, writer: *std.Io.Writer) !void { return writer.writeAll(self.str()); } pub fn eql(a: String, b: String) bool { if (@as(*const u64, @ptrCast(&a)).* != @as(*const u64, @ptrCast(&b)).*) { return false; } const len = a.len; if (len < 0 or b.len < 0) { return false; } if (len <= 12) { return @reduce(.And, a.payload.content == b.payload.content); } // a.len == b.len at this point const al: usize = @intCast(len); const bl: usize = @intCast(len); return std.mem.eql(u8, a.payload.heap.ptr[0..al], b.payload.heap.ptr[0..bl]); } pub fn eqlSlice(a: String, b: []const u8) bool { return switch (a.eqlSliceOrDeleted(b)) { .equal => |r| r, .deleted => false, }; } const EqualOrDeleted = union(enum) { deleted, equal: bool, }; pub fn eqlSliceOrDeleted(a: String, b: []const u8) EqualOrDeleted { if (a.len == tombstone) { return .deleted; } return .{ .equal = std.mem.eql(u8, a.str(), b) }; } // This can be used outside of the small string optimization pub fn intern(input: []const u8) ?[]const u8 { switch (input.len) { 1 => switch (input[0]) { '\n' => return "\n", '\r' => return "\r", '\t' => return "\t", ' ' => return " ", '0' => return "0", '1' => return "1", '2' => return "2", '3' => return "3", '4' => return "4", '5' => return "5", '6' => return "6", '7' => return "7", '8' => return "8", '9' => return "9", '.' => return ".", ',' => return ",", '-' => return "-", '(' => return "(", ')' => return ")", '?' => return "?", ';' => return ";", '=' => return "=", else => {}, }, 2 => switch (@as(u16, @bitCast(input[0..2].*))) { asUint("id") => return "id", asUint(" ") => return " ", asUint("\r\n") => return "\r\n", asUint(", ") => return ", ", asUint("·") => return "·", else => {}, }, 3 => switch (@as(u24, @bitCast(input[0..3].*))) { asUint(" ") => return " ", asUint("•") => return "•", else => {}, }, 4 => switch (@as(u32, @bitCast(input[0..4].*))) { asUint(" ") => return " ", asUint(" to ") => return " to ", else => {}, }, 5 => switch (@as(u40, @bitCast(input[0..5].*))) { asUint(" ") => return " ", asUint(" › ") => return " › ", else => {}, }, 6 => switch (@as(u48, @bitCast(input[0..6].*))) { asUint(" ") => return " ", else => {}, }, 7 => switch (@as(u56, @bitCast(input[0..7].*))) { asUint(" ") => return " ", else => {}, }, 8 => switch (@as(u64, @bitCast(input[0..8].*))) { asUint(" ") => return " ", else => {}, }, 9 => switch (@as(u72, @bitCast(input[0..9].*))) { asUint(" ") => return " ", else => {}, }, 10 => switch (@as(u80, @bitCast(input[0..10].*))) { asUint(" ") => return " ", else => {}, }, 13 => switch (@as(u104, @bitCast(input[0..13].*))) { asUint("border-radius") => return "border-radius", asUint("padding-right") => return "padding-right", asUint("margin-bottom") => return "margin-bottom", asUint("space-between") => return "space-between", else => {}, }, 14 => switch (@as(u112, @bitCast(input[0..14].*))) { asUint("padding-bottom") => return "padding-bottom", asUint("text-transform") => return "text-transform", asUint("letter-spacing") => return "letter-spacing", asUint("vertical-align") => return "vertical-align", else => {}, }, 15 => switch (@as(u120, @bitCast(input[0..15].*))) { asUint("text-decoration") => return "text-decoration", asUint("justify-content") => return "justify-content", else => {}, }, 16 => switch (@as(u128, @bitCast(input[0..16].*))) { asUint("background-color") => return "background-color", else => {}, }, else => {}, } return null; } }; pub fn isAllWhitespace(text: []const u8) bool { return for (text) |c| { if (!std.ascii.isWhitespace(c)) break false; } else true; } // Discriminatory type that signals the bridge to use arena instead of call_arena // Use this for strings that need to persist beyond the current call // The caller can unwrap and store just the underlying .str field pub const Global = struct { str: String, }; fn asUint(comptime string: anytype) std.meta.Int( .unsigned, @bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0 ) { const byteLength = @sizeOf(@TypeOf(string.*)) - 1; const expectedType = *const [byteLength:0]u8; if (@TypeOf(string) != expectedType) { @compileError("expected : " ++ @typeName(expectedType) ++ ", got: " ++ @typeName(@TypeOf(string))); } return @bitCast(@as(*const [byteLength]u8, string).*); } const testing = @import("testing.zig"); test "String" { const other_short = try String.init(undefined, "other_short", .{}); const other_long = try String.init(testing.allocator, "other_long" ** 100, .{}); defer other_long.deinit(testing.allocator); inline for (0..100) |i| { const input = "a" ** i; const str = try String.init(testing.allocator, input, .{}); defer str.deinit(testing.allocator); try testing.expectEqual(input, str.str()); try testing.expectEqual(true, str.eql(str)); try testing.expectEqual(true, str.eqlSlice(input)); try testing.expectEqual(false, str.eql(other_short)); try testing.expectEqual(false, str.eqlSlice("other_short")); try testing.expectEqual(false, str.eql(other_long)); try testing.expectEqual(false, str.eqlSlice("other_long" ** 100)); } } test "String.concat" { { const result = try String.concat(testing.allocator, &.{}); defer result.deinit(testing.allocator); try testing.expectEqual(@as(usize, 0), result.str().len); try testing.expectEqual("", result.str()); } { const result = try String.concat(testing.allocator, &.{"hello"}); defer result.deinit(testing.allocator); try testing.expectEqual("hello", result.str()); } { const result = try String.concat(testing.allocator, &.{ "foo", "bar" }); defer result.deinit(testing.allocator); try testing.expectEqual("foobar", result.str()); try testing.expectEqual(@as(i32, 6), result.len); } { const result = try String.concat(testing.allocator, &.{ "test", "ing", "1234" }); defer result.deinit(testing.allocator); try testing.expectEqual("testing1234", result.str()); try testing.expectEqual(@as(i32, 11), result.len); } { const result = try String.concat(testing.allocator, &.{ "foo", "bar", "baz", "qux" }); defer result.deinit(testing.allocator); try testing.expectEqual("foobarbazqux", result.str()); try testing.expectEqual(@as(i32, 12), result.len); } { const result = try String.concat(testing.allocator, &.{ "hello", " world!" }); defer result.deinit(testing.allocator); try testing.expectEqual("hello world!", result.str()); try testing.expectEqual(@as(i32, 12), result.len); } { const result = try String.concat(testing.allocator, &.{ "a", "b", "c", "d", "e" }); defer result.deinit(testing.allocator); try testing.expectEqual("abcde", result.str()); try testing.expectEqual(@as(i32, 5), result.len); } { const result = try String.concat(testing.allocator, &.{ "one", " ", "two", " ", "three", " ", "four" }); defer result.deinit(testing.allocator); try testing.expectEqual("one two three four", result.str()); try testing.expectEqual(@as(i32, 18), result.len); } { const result = try String.concat(testing.allocator, &.{ "hello", "", "world" }); defer result.deinit(testing.allocator); try testing.expectEqual("helloworld", result.str()); } { const result = try String.concat(testing.allocator, &.{ "", "", "" }); defer result.deinit(testing.allocator); try testing.expectEqual("", result.str()); try testing.expectEqual(@as(i32, 0), result.len); } { const result = try String.concat(testing.allocator, &.{ "café", " ☕" }); defer result.deinit(testing.allocator); try testing.expectEqual("café ☕", result.str()); } { const result = try String.concat(testing.allocator, &.{ "Hello ", "世界", " and ", "مرحبا" }); defer result.deinit(testing.allocator); try testing.expectEqual("Hello 世界 and مرحبا", result.str()); } { const result = try String.concat(testing.allocator, &.{ " ", "test", " " }); defer result.deinit(testing.allocator); try testing.expectEqual(" test ", result.str()); } { const result = try String.concat(testing.allocator, &.{ " ", " " }); defer result.deinit(testing.allocator); try testing.expectEqual(" ", result.str()); try testing.expectEqual(@as(i32, 4), result.len); } { const result = try String.concat(testing.allocator, &.{ "Item ", "1", "2", "3" }); defer result.deinit(testing.allocator); try testing.expectEqual("Item 123", result.str()); } { const original = "Hello, world!"; const result = try String.concat(testing.allocator, &.{ original[0..5], original[7..] }); defer result.deinit(testing.allocator); try testing.expectEqual("Helloworld!", result.str()); } { const original = "Hello!"; const result = try String.concat(testing.allocator, &.{ original[0..5], " world", original[5..] }); defer result.deinit(testing.allocator); try testing.expectEqual("Hello world!", result.str()); } } ================================================ FILE: src/sys/libcurl.zig ================================================ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); const c = @cImport({ @cInclude("curl/curl.h"); }); const IS_DEBUG = builtin.mode == .Debug; pub const Curl = c.CURL; pub const CurlM = c.CURLM; pub const CurlCode = c.CURLcode; pub const CurlMCode = c.CURLMcode; pub const CurlSList = c.curl_slist; pub const CurlHeader = c.curl_header; pub const CurlHttpPost = c.curl_httppost; pub const CurlSocket = c.curl_socket_t; pub const CurlBlob = c.curl_blob; pub const CurlOffT = c.curl_off_t; pub const CurlDebugFunction = fn (*Curl, CurlInfoType, [*c]u8, usize, *anyopaque) c_int; pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; pub const FreeCallback = fn (ptr: ?*anyopaque) void; pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8; pub const MallocCallback = fn (size: usize) ?*anyopaque; pub const CallocCallback = fn (nmemb: usize, size: usize) ?*anyopaque; pub const ReallocCallback = fn (ptr: ?*anyopaque, size: usize) ?*anyopaque; pub const CurlAllocator = struct { free: FreeCallback, strdup: StrdupCallback, malloc: MallocCallback, calloc: CallocCallback, realloc: ReallocCallback, }; pub const CurlGlobalFlags = packed struct(u8) { ssl: bool = false, _reserved: u7 = 0, pub fn to_c(self: @This()) c_long { var flags: c_long = 0; if (self.ssl) flags |= c.CURL_GLOBAL_SSL; return flags; } }; pub const CurlHeaderOrigin = enum(c_uint) { header = c.CURLH_HEADER, trailer = c.CURLH_TRAILER, connect = c.CURLH_CONNECT, @"1xx" = c.CURLH_1XX, pseudo = c.CURLH_PSEUDO, }; pub const CurlWaitEvents = packed struct(c_short) { pollin: bool = false, pollpri: bool = false, pollout: bool = false, _reserved: u13 = 0, }; pub const CurlInfoType = enum(c.curl_infotype) { text = c.CURLINFO_TEXT, header_in = c.CURLINFO_HEADER_IN, header_out = c.CURLINFO_HEADER_OUT, data_in = c.CURLINFO_DATA_IN, data_out = c.CURLINFO_DATA_OUT, ssl_data_in = c.CURLINFO_SSL_DATA_IN, ssl_data_out = c.CURLINFO_SSL_DATA_OUT, end = c.CURLINFO_END, }; pub const CurlWaitFd = extern struct { fd: CurlSocket, events: CurlWaitEvents, revents: CurlWaitEvents, }; comptime { const debug_cb_check: c.curl_debug_callback = struct { fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int { _ = handle; _ = msg_type; _ = raw; _ = len; _ = user; return 0; } }.cb; const write_cb_check: c.curl_write_callback = struct { fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { _ = buffer; _ = count; _ = len; _ = user; return 0; } }.cb; _ = debug_cb_check; _ = write_cb_check; if (@sizeOf(CurlWaitFd) != @sizeOf(c.curl_waitfd)) { @compileError("CurlWaitFd size mismatch"); } if (@offsetOf(CurlWaitFd, "fd") != @offsetOf(c.curl_waitfd, "fd") or @offsetOf(CurlWaitFd, "events") != @offsetOf(c.curl_waitfd, "events") or @offsetOf(CurlWaitFd, "revents") != @offsetOf(c.curl_waitfd, "revents")) { @compileError("CurlWaitFd layout mismatch"); } if (c.CURL_WAIT_POLLIN != 1 or c.CURL_WAIT_POLLPRI != 2 or c.CURL_WAIT_POLLOUT != 4) { @compileError("CURL_WAIT_* flag values don't match CurlWaitEvents packed struct bit layout"); } } pub const CurlOption = enum(c.CURLoption) { url = c.CURLOPT_URL, timeout_ms = c.CURLOPT_TIMEOUT_MS, connect_timeout_ms = c.CURLOPT_CONNECTTIMEOUT_MS, max_redirs = c.CURLOPT_MAXREDIRS, follow_location = c.CURLOPT_FOLLOWLOCATION, redir_protocols_str = c.CURLOPT_REDIR_PROTOCOLS_STR, proxy = c.CURLOPT_PROXY, ca_info_blob = c.CURLOPT_CAINFO_BLOB, proxy_ca_info_blob = c.CURLOPT_PROXY_CAINFO_BLOB, ssl_verify_host = c.CURLOPT_SSL_VERIFYHOST, ssl_verify_peer = c.CURLOPT_SSL_VERIFYPEER, proxy_ssl_verify_host = c.CURLOPT_PROXY_SSL_VERIFYHOST, proxy_ssl_verify_peer = c.CURLOPT_PROXY_SSL_VERIFYPEER, accept_encoding = c.CURLOPT_ACCEPT_ENCODING, verbose = c.CURLOPT_VERBOSE, debug_function = c.CURLOPT_DEBUGFUNCTION, custom_request = c.CURLOPT_CUSTOMREQUEST, post = c.CURLOPT_POST, http_post = c.CURLOPT_HTTPPOST, post_field_size = c.CURLOPT_POSTFIELDSIZE, copy_post_fields = c.CURLOPT_COPYPOSTFIELDS, http_get = c.CURLOPT_HTTPGET, http_header = c.CURLOPT_HTTPHEADER, cookie = c.CURLOPT_COOKIE, private = c.CURLOPT_PRIVATE, proxy_user_pwd = c.CURLOPT_PROXYUSERPWD, user_pwd = c.CURLOPT_USERPWD, header_data = c.CURLOPT_HEADERDATA, header_function = c.CURLOPT_HEADERFUNCTION, write_data = c.CURLOPT_WRITEDATA, write_function = c.CURLOPT_WRITEFUNCTION, }; pub const CurlMOption = enum(c.CURLMoption) { max_host_connections = c.CURLMOPT_MAX_HOST_CONNECTIONS, }; pub const CurlInfo = enum(c.CURLINFO) { effective_url = c.CURLINFO_EFFECTIVE_URL, private = c.CURLINFO_PRIVATE, redirect_count = c.CURLINFO_REDIRECT_COUNT, response_code = c.CURLINFO_RESPONSE_CODE, }; pub const Error = error{ UnsupportedProtocol, FailedInit, UrlMalformat, NotBuiltIn, CouldntResolveProxy, CouldntResolveHost, CouldntConnect, WeirdServerReply, RemoteAccessDenied, FtpAcceptFailed, FtpWeirdPassReply, FtpAcceptTimeout, FtpWeirdPasvReply, FtpWeird227Format, FtpCantGetHost, Http2, FtpCouldntSetType, PartialFile, FtpCouldntRetrFile, QuoteError, HttpReturnedError, WriteError, UploadFailed, ReadError, OutOfMemory, OperationTimedout, FtpPortFailed, FtpCouldntUseRest, RangeError, SslConnectError, BadDownloadResume, FileCouldntReadFile, LdapCannotBind, LdapSearchFailed, AbortedByCallback, BadFunctionArgument, InterfaceFailed, TooManyRedirects, UnknownOption, SetoptOptionSyntax, GotNothing, SslEngineNotfound, SslEngineSetfailed, SendError, RecvError, SslCertproblem, SslCipher, PeerFailedVerification, BadContentEncoding, FilesizeExceeded, UseSslFailed, SendFailRewind, SslEngineInitfailed, LoginDenied, TftpNotfound, TftpPerm, RemoteDiskFull, TftpIllegal, TftpUnknownid, RemoteFileExists, TftpNosuchuser, SslCacertBadfile, RemoteFileNotFound, Ssh, SslShutdownFailed, Again, SslCrlBadfile, SslIssuerError, FtpPretFailed, RtspCseqError, RtspSessionError, FtpBadFileList, ChunkFailed, NoConnectionAvailable, SslPinnedpubkeynotmatch, SslInvalidcertstatus, Http2Stream, RecursiveApiCall, AuthError, Http3, QuicConnectError, Proxy, SslClientcert, UnrecoverablePoll, TooLarge, Unknown, }; pub fn errorFromCode(code: c.CURLcode) Error { if (comptime IS_DEBUG) { std.debug.assert(code != c.CURLE_OK); } return switch (code) { c.CURLE_UNSUPPORTED_PROTOCOL => Error.UnsupportedProtocol, c.CURLE_FAILED_INIT => Error.FailedInit, c.CURLE_URL_MALFORMAT => Error.UrlMalformat, c.CURLE_NOT_BUILT_IN => Error.NotBuiltIn, c.CURLE_COULDNT_RESOLVE_PROXY => Error.CouldntResolveProxy, c.CURLE_COULDNT_RESOLVE_HOST => Error.CouldntResolveHost, c.CURLE_COULDNT_CONNECT => Error.CouldntConnect, c.CURLE_WEIRD_SERVER_REPLY => Error.WeirdServerReply, c.CURLE_REMOTE_ACCESS_DENIED => Error.RemoteAccessDenied, c.CURLE_FTP_ACCEPT_FAILED => Error.FtpAcceptFailed, c.CURLE_FTP_WEIRD_PASS_REPLY => Error.FtpWeirdPassReply, c.CURLE_FTP_ACCEPT_TIMEOUT => Error.FtpAcceptTimeout, c.CURLE_FTP_WEIRD_PASV_REPLY => Error.FtpWeirdPasvReply, c.CURLE_FTP_WEIRD_227_FORMAT => Error.FtpWeird227Format, c.CURLE_FTP_CANT_GET_HOST => Error.FtpCantGetHost, c.CURLE_HTTP2 => Error.Http2, c.CURLE_FTP_COULDNT_SET_TYPE => Error.FtpCouldntSetType, c.CURLE_PARTIAL_FILE => Error.PartialFile, c.CURLE_FTP_COULDNT_RETR_FILE => Error.FtpCouldntRetrFile, c.CURLE_QUOTE_ERROR => Error.QuoteError, c.CURLE_HTTP_RETURNED_ERROR => Error.HttpReturnedError, c.CURLE_WRITE_ERROR => Error.WriteError, c.CURLE_UPLOAD_FAILED => Error.UploadFailed, c.CURLE_READ_ERROR => Error.ReadError, c.CURLE_OUT_OF_MEMORY => Error.OutOfMemory, c.CURLE_OPERATION_TIMEDOUT => Error.OperationTimedout, c.CURLE_FTP_PORT_FAILED => Error.FtpPortFailed, c.CURLE_FTP_COULDNT_USE_REST => Error.FtpCouldntUseRest, c.CURLE_RANGE_ERROR => Error.RangeError, c.CURLE_SSL_CONNECT_ERROR => Error.SslConnectError, c.CURLE_BAD_DOWNLOAD_RESUME => Error.BadDownloadResume, c.CURLE_FILE_COULDNT_READ_FILE => Error.FileCouldntReadFile, c.CURLE_LDAP_CANNOT_BIND => Error.LdapCannotBind, c.CURLE_LDAP_SEARCH_FAILED => Error.LdapSearchFailed, c.CURLE_ABORTED_BY_CALLBACK => Error.AbortedByCallback, c.CURLE_BAD_FUNCTION_ARGUMENT => Error.BadFunctionArgument, c.CURLE_INTERFACE_FAILED => Error.InterfaceFailed, c.CURLE_TOO_MANY_REDIRECTS => Error.TooManyRedirects, c.CURLE_UNKNOWN_OPTION => Error.UnknownOption, c.CURLE_SETOPT_OPTION_SYNTAX => Error.SetoptOptionSyntax, c.CURLE_GOT_NOTHING => Error.GotNothing, c.CURLE_SSL_ENGINE_NOTFOUND => Error.SslEngineNotfound, c.CURLE_SSL_ENGINE_SETFAILED => Error.SslEngineSetfailed, c.CURLE_SEND_ERROR => Error.SendError, c.CURLE_RECV_ERROR => Error.RecvError, c.CURLE_SSL_CERTPROBLEM => Error.SslCertproblem, c.CURLE_SSL_CIPHER => Error.SslCipher, c.CURLE_PEER_FAILED_VERIFICATION => Error.PeerFailedVerification, c.CURLE_BAD_CONTENT_ENCODING => Error.BadContentEncoding, c.CURLE_FILESIZE_EXCEEDED => Error.FilesizeExceeded, c.CURLE_USE_SSL_FAILED => Error.UseSslFailed, c.CURLE_SEND_FAIL_REWIND => Error.SendFailRewind, c.CURLE_SSL_ENGINE_INITFAILED => Error.SslEngineInitfailed, c.CURLE_LOGIN_DENIED => Error.LoginDenied, c.CURLE_TFTP_NOTFOUND => Error.TftpNotfound, c.CURLE_TFTP_PERM => Error.TftpPerm, c.CURLE_REMOTE_DISK_FULL => Error.RemoteDiskFull, c.CURLE_TFTP_ILLEGAL => Error.TftpIllegal, c.CURLE_TFTP_UNKNOWNID => Error.TftpUnknownid, c.CURLE_REMOTE_FILE_EXISTS => Error.RemoteFileExists, c.CURLE_TFTP_NOSUCHUSER => Error.TftpNosuchuser, c.CURLE_SSL_CACERT_BADFILE => Error.SslCacertBadfile, c.CURLE_REMOTE_FILE_NOT_FOUND => Error.RemoteFileNotFound, c.CURLE_SSH => Error.Ssh, c.CURLE_SSL_SHUTDOWN_FAILED => Error.SslShutdownFailed, c.CURLE_AGAIN => Error.Again, c.CURLE_SSL_CRL_BADFILE => Error.SslCrlBadfile, c.CURLE_SSL_ISSUER_ERROR => Error.SslIssuerError, c.CURLE_FTP_PRET_FAILED => Error.FtpPretFailed, c.CURLE_RTSP_CSEQ_ERROR => Error.RtspCseqError, c.CURLE_RTSP_SESSION_ERROR => Error.RtspSessionError, c.CURLE_FTP_BAD_FILE_LIST => Error.FtpBadFileList, c.CURLE_CHUNK_FAILED => Error.ChunkFailed, c.CURLE_NO_CONNECTION_AVAILABLE => Error.NoConnectionAvailable, c.CURLE_SSL_PINNEDPUBKEYNOTMATCH => Error.SslPinnedpubkeynotmatch, c.CURLE_SSL_INVALIDCERTSTATUS => Error.SslInvalidcertstatus, c.CURLE_HTTP2_STREAM => Error.Http2Stream, c.CURLE_RECURSIVE_API_CALL => Error.RecursiveApiCall, c.CURLE_AUTH_ERROR => Error.AuthError, c.CURLE_HTTP3 => Error.Http3, c.CURLE_QUIC_CONNECT_ERROR => Error.QuicConnectError, c.CURLE_PROXY => Error.Proxy, c.CURLE_SSL_CLIENTCERT => Error.SslClientcert, c.CURLE_UNRECOVERABLE_POLL => Error.UnrecoverablePoll, c.CURLE_TOO_LARGE => Error.TooLarge, else => Error.Unknown, }; } pub const ErrorMulti = error{ BadHandle, BadEasyHandle, OutOfMemory, InternalError, BadSocket, UnknownOption, AddedAlready, RecursiveApiCall, WakeupFailure, BadFunctionArgument, AbortedByCallback, UnrecoverablePoll, Unknown, }; pub const ErrorHeader = error{ OutOfMemory, BadArgument, NotBuiltIn, Unknown, }; pub fn errorMFromCode(code: c.CURLMcode) ErrorMulti { if (comptime IS_DEBUG) { std.debug.assert(code != c.CURLM_OK); } return switch (code) { c.CURLM_BAD_HANDLE => ErrorMulti.BadHandle, c.CURLM_BAD_EASY_HANDLE => ErrorMulti.BadEasyHandle, c.CURLM_OUT_OF_MEMORY => ErrorMulti.OutOfMemory, c.CURLM_INTERNAL_ERROR => ErrorMulti.InternalError, c.CURLM_BAD_SOCKET => ErrorMulti.BadSocket, c.CURLM_UNKNOWN_OPTION => ErrorMulti.UnknownOption, c.CURLM_ADDED_ALREADY => ErrorMulti.AddedAlready, c.CURLM_RECURSIVE_API_CALL => ErrorMulti.RecursiveApiCall, c.CURLM_WAKEUP_FAILURE => ErrorMulti.WakeupFailure, c.CURLM_BAD_FUNCTION_ARGUMENT => ErrorMulti.BadFunctionArgument, c.CURLM_ABORTED_BY_CALLBACK => ErrorMulti.AbortedByCallback, c.CURLM_UNRECOVERABLE_POLL => ErrorMulti.UnrecoverablePoll, else => ErrorMulti.Unknown, }; } pub fn errorHFromCode(code: c.CURLHcode) ErrorHeader { if (comptime IS_DEBUG) { std.debug.assert(code != c.CURLHE_OK); } return switch (code) { c.CURLHE_OUT_OF_MEMORY => ErrorHeader.OutOfMemory, c.CURLHE_BAD_ARGUMENT => ErrorHeader.BadArgument, c.CURLHE_NOT_BUILT_IN => ErrorHeader.NotBuiltIn, else => ErrorHeader.Unknown, }; } pub fn errorCheck(code: c.CURLcode) Error!void { if (code == c.CURLE_OK) { return; } return errorFromCode(code); } pub fn errorMCheck(code: c.CURLMcode) ErrorMulti!void { if (code == c.CURLM_OK) { return; } if (code == c.CURLM_CALL_MULTI_PERFORM) { return; } return errorMFromCode(code); } pub fn errorHCheck(code: c.CURLHcode) ErrorHeader!void { if (code == c.CURLHE_OK) { return; } return errorHFromCode(code); } pub const CurlMsgType = enum(c.CURLMSG) { none = c.CURLMSG_NONE, done = c.CURLMSG_DONE, last = c.CURLMSG_LAST, }; pub const CurlMsgData = union(CurlMsgType) { none: ?*anyopaque, done: ?Error, last: ?*anyopaque, }; pub const CurlMsg = struct { easy_handle: *Curl, data: CurlMsgData, }; pub fn curl_global_init(flags: CurlGlobalFlags, comptime curl_allocator: ?CurlAllocator) Error!void { const alloc = curl_allocator orelse { return errorCheck(c.curl_global_init(flags.to_c())); }; // The purpose of these wrappers is to hide callconv // and provide an easy place to add logging when debugging. const free = struct { fn cb(ptr: ?*anyopaque) callconv(.c) void { alloc.free(ptr); } }.cb; const strdup = struct { fn cb(str: [*c]const u8) callconv(.c) [*c]u8 { const s: [*:0]const u8 = @ptrCast(str orelse return null); return @ptrCast(alloc.strdup(s)); } }.cb; const malloc = struct { fn cb(size: usize) callconv(.c) ?*anyopaque { return alloc.malloc(size); } }.cb; const calloc = struct { fn cb(nmemb: usize, size: usize) callconv(.c) ?*anyopaque { return alloc.calloc(nmemb, size); } }.cb; const realloc = struct { fn cb(ptr: ?*anyopaque, size: usize) callconv(.c) ?*anyopaque { return alloc.realloc(ptr, size); } }.cb; try errorCheck(c.curl_global_init_mem(flags.to_c(), malloc, free, realloc, strdup, calloc)); } pub fn curl_global_cleanup() void { c.curl_global_cleanup(); } pub fn curl_version() [*c]const u8 { return c.curl_version(); } pub fn curl_easy_init() ?*Curl { return c.curl_easy_init(); } pub fn curl_easy_cleanup(easy: *Curl) void { c.curl_easy_cleanup(easy); } pub fn curl_easy_perform(easy: *Curl) Error!void { try errorCheck(c.curl_easy_perform(easy)); } pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype) Error!void { const opt: c.CURLoption = @intFromEnum(option); const code = switch (option) { .verbose, .post, .http_get, .ssl_verify_host, .ssl_verify_peer, .proxy_ssl_verify_host, .proxy_ssl_verify_peer, => blk: { const n: c_long = switch (@typeInfo(@TypeOf(value))) { .bool => switch (option) { .ssl_verify_host, .proxy_ssl_verify_host => if (value) 2 else 0, else => if (value) 1 else 0, }, else => @compileError("expected bool|integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), }; break :blk c.curl_easy_setopt(easy, opt, n); }, .timeout_ms, .connect_timeout_ms, .max_redirs, .follow_location, .post_field_size, => blk: { const n: c_long = switch (@typeInfo(@TypeOf(value))) { .comptime_int, .int => @intCast(value), else => @compileError("expected integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), }; break :blk c.curl_easy_setopt(easy, opt, n); }, .url, .redir_protocols_str, .proxy, .accept_encoding, .custom_request, .cookie, .user_pwd, .proxy_user_pwd, .copy_post_fields, => blk: { const s: ?[*]const u8 = value; break :blk c.curl_easy_setopt(easy, opt, s); }, .ca_info_blob, .proxy_ca_info_blob, => blk: { const blob: CurlBlob = value; break :blk c.curl_easy_setopt(easy, opt, blob); }, .http_post => blk: { // CURLOPT_HTTPPOST expects ?*curl_httppost (multipart formdata) const ptr: ?*CurlHttpPost = value; break :blk c.curl_easy_setopt(easy, opt, ptr); }, .http_header => blk: { const list: ?*CurlSList = value; break :blk c.curl_easy_setopt(easy, opt, list); }, .private, .header_data, .write_data, => blk: { const ptr: ?*anyopaque = switch (@typeInfo(@TypeOf(value))) { .null => null, else => @ptrCast(value), }; break :blk c.curl_easy_setopt(easy, opt, ptr); }, .debug_function => blk: { const cb: c.curl_debug_callback = switch (@typeInfo(@TypeOf(value))) { .null => null, .@"fn" => struct { fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int { const h = handle orelse unreachable; const u = user orelse unreachable; return value(h, @enumFromInt(@intFromEnum(msg_type)), raw, len, u); } }.cb, else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), }; break :blk c.curl_easy_setopt(easy, opt, cb); }, .header_function => blk: { const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { .null => null, .@"fn" => struct { fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { const u = user orelse unreachable; return value(@ptrCast(buffer), count, len, u); } }.cb, else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), }; break :blk c.curl_easy_setopt(easy, opt, cb); }, .write_function => blk: { const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { .null => null, .@"fn" => |info| struct { fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { const user_arg = if (@typeInfo(info.params[3].type.?) == .optional) user else user orelse unreachable; return value(@ptrCast(buffer), count, len, user_arg); } }.cb, else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), }; break :blk c.curl_easy_setopt(easy, opt, cb); }, }; try errorCheck(code); } pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Error!void { if (@typeInfo(@TypeOf(out)) != .pointer) { @compileError("curl_easy_getinfo out must be a pointer, got " ++ @typeName(@TypeOf(out))); } const inf: c.CURLINFO = @intFromEnum(info); const code = switch (info) { .effective_url => blk: { const p: *[*c]u8 = out; break :blk c.curl_easy_getinfo(easy, inf, p); }, .response_code, .redirect_count, => blk: { const p: *c_long = out; break :blk c.curl_easy_getinfo(easy, inf, p); }, .private => blk: { const p: **anyopaque = out; break :blk c.curl_easy_getinfo(easy, inf, p); }, }; try errorCheck(code); } pub fn curl_easy_header( easy: *Curl, name: [*:0]const u8, index: usize, comptime origin: CurlHeaderOrigin, request: c_int, hout: *?*CurlHeader, ) ErrorHeader!void { var c_hout: [*c]CurlHeader = null; const code = c.curl_easy_header(easy, name, index, @intFromEnum(origin), request, &c_hout); switch (code) { c.CURLHE_OK => { hout.* = @ptrCast(c_hout); return; }, c.CURLHE_BADINDEX, c.CURLHE_MISSING, c.CURLHE_NOHEADERS, c.CURLHE_NOREQUEST, => { hout.* = null; return; }, else => { hout.* = null; return errorHFromCode(code); }, } } pub fn curl_easy_nextheader( easy: *Curl, comptime origin: CurlHeaderOrigin, request: c_int, prev: ?*CurlHeader, ) ?*CurlHeader { const ptr = c.curl_easy_nextheader(easy, @intFromEnum(origin), request, prev); if (ptr == null) return null; return @ptrCast(ptr); } pub fn curl_multi_init() ?*CurlM { return c.curl_multi_init(); } pub fn curl_multi_cleanup(multi: *CurlM) ErrorMulti!void { try errorMCheck(c.curl_multi_cleanup(multi)); } pub fn curl_multi_setopt(multi: *CurlM, comptime option: CurlMOption, value: anytype) ErrorMulti!void { const opt: c.CURLMoption = @intFromEnum(option); const code = switch (option) { .max_host_connections => blk: { const n: c_long = switch (@typeInfo(@TypeOf(value))) { .comptime_int, .int => @intCast(value), else => @compileError("expected integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), }; break :blk c.curl_multi_setopt(multi, opt, n); }, }; try errorMCheck(code); } pub fn curl_multi_add_handle(multi: *CurlM, easy: *Curl) ErrorMulti!void { try errorMCheck(c.curl_multi_add_handle(multi, easy)); } pub fn curl_multi_remove_handle(multi: *CurlM, easy: *Curl) ErrorMulti!void { try errorMCheck(c.curl_multi_remove_handle(multi, easy)); } pub fn curl_multi_perform(multi: *CurlM, running_handles: *c_int) ErrorMulti!void { try errorMCheck(c.curl_multi_perform(multi, running_handles)); } pub fn curl_multi_poll( multi: *CurlM, extra_fds: []CurlWaitFd, timeout_ms: c_int, numfds: ?*c_int, ) ErrorMulti!void { const raw_fds: [*c]c.curl_waitfd = if (extra_fds.len == 0) null else @ptrCast(extra_fds.ptr); try errorMCheck(c.curl_multi_poll(multi, raw_fds, @intCast(extra_fds.len), timeout_ms, numfds)); } pub fn curl_multi_waitfds(multi: *CurlM, ufds: []CurlWaitFd, fd_count: *c_uint) ErrorMulti!void { const raw_fds: [*c]c.curl_waitfd = if (ufds.len == 0) null else @ptrCast(ufds.ptr); try errorMCheck(c.curl_multi_waitfds(multi, raw_fds, @intCast(ufds.len), fd_count)); } pub fn curl_multi_timeout(multi: *CurlM, timeout_ms: *c_long) ErrorMulti!void { try errorMCheck(c.curl_multi_timeout(multi, timeout_ms)); } pub fn curl_multi_info_read(multi: *CurlM, msgs_in_queue: *c_int) ?CurlMsg { const ptr = c.curl_multi_info_read(multi, msgs_in_queue); if (ptr == null) return null; const msg: *const c.CURLMsg = @ptrCast(ptr); const easy_handle = msg.easy_handle orelse unreachable; return switch (msg.msg) { c.CURLMSG_NONE => .{ .easy_handle = easy_handle, .data = .{ .none = msg.data.whatever }, }, c.CURLMSG_DONE => .{ .easy_handle = easy_handle, .data = .{ .done = if (errorCheck(msg.data.result)) |_| null else |err| err }, }, c.CURLMSG_LAST => .{ .easy_handle = easy_handle, .data = .{ .last = msg.data.whatever }, }, else => unreachable, }; } pub fn curl_slist_append(list: ?*CurlSList, header: [*:0]const u8) ?*CurlSList { return c.curl_slist_append(list, header); } pub fn curl_slist_free_all(list: ?*CurlSList) void { if (list) |ptr| { c.curl_slist_free_all(ptr); } } ================================================ FILE: src/telemetry/lightpanda.zig ================================================ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("build_config"); const Allocator = std.mem.Allocator; const log = @import("../log.zig"); const App = @import("../App.zig"); const Config = @import("../Config.zig"); const telemetry = @import("telemetry.zig"); const Runtime = @import("../network/Runtime.zig"); const Connection = @import("../network/http.zig").Connection; const URL = "https://telemetry.lightpanda.io"; const BUFFER_SIZE = 1024; const MAX_BODY_SIZE = 500 * 1024; // 500KB server limit const LightPanda = @This(); allocator: Allocator, runtime: *Runtime, writer: std.Io.Writer.Allocating, /// Protects concurrent producers in send(). mutex: std.Thread.Mutex = .{}, iid: ?[36]u8 = null, run_mode: Config.RunMode = .serve, head: std.atomic.Value(usize) = .init(0), tail: std.atomic.Value(usize) = .init(0), dropped: std.atomic.Value(usize) = .init(0), buffer: [BUFFER_SIZE]telemetry.Event = undefined, pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode) !void { self.* = .{ .iid = iid, .run_mode = run_mode, .allocator = app.allocator, .runtime = &app.network, .writer = std.Io.Writer.Allocating.init(app.allocator), }; self.runtime.onTick(@ptrCast(self), flushCallback); } pub fn deinit(self: *LightPanda) void { self.writer.deinit(); } pub fn send(self: *LightPanda, raw_event: telemetry.Event) !void { self.mutex.lock(); defer self.mutex.unlock(); const t = self.tail.load(.monotonic); const h = self.head.load(.acquire); if (t - h >= BUFFER_SIZE) { _ = self.dropped.fetchAdd(1, .monotonic); return; } self.buffer[t % BUFFER_SIZE] = raw_event; self.tail.store(t + 1, .release); } fn flushCallback(ctx: *anyopaque) void { const self: *LightPanda = @ptrCast(@alignCast(ctx)); self.postEvent() catch |err| { log.warn(.telemetry, "flush error", .{ .err = err }); }; } fn postEvent(self: *LightPanda) !void { const conn = self.runtime.getConnection() orelse { return; }; errdefer self.runtime.releaseConnection(conn); const h = self.head.load(.monotonic); const t = self.tail.load(.acquire); const dropped = self.dropped.swap(0, .monotonic); if (h == t and dropped == 0) { self.runtime.releaseConnection(conn); return; } errdefer _ = self.dropped.fetchAdd(dropped, .monotonic); self.writer.clearRetainingCapacity(); if (dropped > 0) { _ = try self.writeEvent(.{ .buffer_overflow = .{ .dropped = dropped } }); } var sent: usize = 0; for (h..t) |i| { const fit = try self.writeEvent(self.buffer[i % BUFFER_SIZE]); if (!fit) break; sent += 1; } try conn.setURL(URL); try conn.setMethod(.POST); try conn.setBody(self.writer.written()); self.head.store(h + sent, .release); self.runtime.submitRequest(conn); } fn writeEvent(self: *LightPanda, event: telemetry.Event) !bool { const iid: ?[]const u8 = if (self.iid) |*id| id else null; const wrapped = LightPandaEvent{ .iid = iid, .mode = self.run_mode, .event = event }; const checkpoint = self.writer.written().len; try std.json.Stringify.value(&wrapped, .{ .emit_null_optional_fields = false }, &self.writer.writer); try self.writer.writer.writeByte('\n'); if (self.writer.written().len > MAX_BODY_SIZE) { self.writer.shrinkRetainingCapacity(checkpoint); return false; } return true; } const LightPandaEvent = struct { iid: ?[]const u8, mode: Config.RunMode, event: telemetry.Event, pub fn jsonStringify(self: *const LightPandaEvent, writer: anytype) !void { try writer.beginObject(); try writer.objectField("iid"); try writer.write(self.iid); try writer.objectField("mode"); try writer.write(self.mode); try writer.objectField("os"); try writer.write(builtin.os.tag); try writer.objectField("arch"); try writer.write(builtin.cpu.arch); try writer.objectField("version"); try writer.write(build_config.git_version orelse build_config.git_commit); try writer.objectField("event"); try writer.write(@tagName(std.meta.activeTag(self.event))); inline for (@typeInfo(telemetry.Event).@"union".fields) |union_field| { if (self.event == @field(telemetry.Event, union_field.name)) { const inner = @field(self.event, union_field.name); const TI = @typeInfo(@TypeOf(inner)); if (TI == .@"struct") { inline for (TI.@"struct".fields) |field| { try writer.objectField(field.name); try writer.write(@field(inner, field.name)); } } } } try writer.endObject(); } }; ================================================ FILE: src/telemetry/telemetry.zig ================================================ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const log = @import("../log.zig"); const App = @import("../App.zig"); const Config = @import("../Config.zig"); const uuidv4 = @import("../id.zig").uuidv4; const IID_FILE = "iid"; pub fn isDisabled() bool { if (builtin.mode == .Debug or builtin.is_test) { return true; } return std.process.hasEnvVarConstant("LIGHTPANDA_DISABLE_TELEMETRY"); } pub const Telemetry = TelemetryT(@import("lightpanda.zig")); fn TelemetryT(comptime P: type) type { return struct { provider: *P, disabled: bool, const Self = @This(); pub fn init(app: *App, run_mode: Config.RunMode) !Self { const disabled = isDisabled(); if (builtin.mode != .Debug and builtin.is_test == false) { log.info(.telemetry, "telemetry status", .{ .disabled = disabled }); } const iid: ?[36]u8 = if (disabled) null else getOrCreateId(app.app_dir_path); const provider = try app.allocator.create(P); errdefer app.allocator.destroy(provider); try P.init(provider, app, iid, run_mode); return .{ .disabled = disabled, .provider = provider, }; } pub fn deinit(self: *Self, allocator: Allocator) void { self.provider.deinit(); allocator.destroy(self.provider); } pub fn record(self: *Self, event: Event) void { if (self.disabled) { return; } self.provider.send(event) catch |err| { log.warn(.telemetry, "record error", .{ .err = err, .type = @tagName(std.meta.activeTag(event)) }); }; } }; } fn getOrCreateId(app_dir_path_: ?[]const u8) ?[36]u8 { const app_dir_path = app_dir_path_ orelse { var id: [36]u8 = undefined; uuidv4(&id); return id; }; var buf: [37]u8 = undefined; var dir = std.fs.openDirAbsolute(app_dir_path, .{}) catch |err| { log.warn(.telemetry, "data directory open error", .{ .path = app_dir_path, .err = err }); return null; }; defer dir.close(); const data = dir.readFile(IID_FILE, &buf) catch |err| switch (err) { error.FileNotFound => &.{}, else => { log.warn(.telemetry, "ID read error", .{ .path = app_dir_path, .err = err }); return null; }, }; var id: [36]u8 = undefined; if (data.len == 36) { @memcpy(id[0..36], data); return id; } uuidv4(&id); dir.writeFile(.{ .sub_path = IID_FILE, .data = &id }) catch |err| { log.warn(.telemetry, "ID write error", .{ .path = app_dir_path, .err = err }); return null; }; return id; } pub const Event = union(enum) { run: void, navigate: Navigate, buffer_overflow: BufferOverflow, flag: []const u8, // used for testing const Navigate = struct { tls: bool, proxy: bool, driver: []const u8 = "cdp", }; const BufferOverflow = struct { dropped: usize, }; }; extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int; extern fn unsetenv(name: [*:0]u8) c_int; const testing = @import("../testing.zig"); test "telemetry: always disabled in debug builds" { // Must be disabled regardless of environment variable. _ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY")); try testing.expectEqual(true, isDisabled()); _ = setenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"), @constCast(""), 0); defer _ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY")); try testing.expectEqual(true, isDisabled()); const FailingProvider = struct { fn init(_: *@This(), _: *App, _: ?[36]u8, _: Config.RunMode) !void {} fn deinit(_: *@This()) void {} pub fn send(_: *@This(), _: Event) !void { unreachable; } }; var telemetry = try TelemetryT(FailingProvider).init(testing.test_app, .serve); defer telemetry.deinit(testing.test_app.allocator); telemetry.record(.{ .run = {} }); } test "telemetry: getOrCreateId" { defer std.fs.cwd().deleteFile("/tmp/" ++ IID_FILE) catch {}; std.fs.cwd().deleteFile("/tmp/" ++ IID_FILE) catch {}; const id1 = getOrCreateId("/tmp/").?; const id2 = getOrCreateId("/tmp/").?; try testing.expectEqual(&id1, &id2); std.fs.cwd().deleteFile("/tmp/" ++ IID_FILE) catch {}; const id3 = getOrCreateId("/tmp/").?; try testing.expectEqual(false, std.mem.eql(u8, &id1, &id3)); const id4 = getOrCreateId(null).?; try testing.expectEqual(false, std.mem.eql(u8, &id1, &id4)); try testing.expectEqual(false, std.mem.eql(u8, &id3, &id4)); } test "telemetry: sends event to provider" { var telemetry = try TelemetryT(MockProvider).init(testing.test_app, .serve); defer telemetry.deinit(testing.test_app.allocator); telemetry.disabled = false; const mock = telemetry.provider; telemetry.record(.{ .flag = "1" }); telemetry.record(.{ .flag = "2" }); telemetry.record(.{ .flag = "3" }); try testing.expectEqual(3, mock.events.items.len); for (mock.events.items, 0..) |event, i| { try testing.expectEqual(i + 1, std.fmt.parseInt(usize, event.flag, 10)); } } const MockProvider = struct { allocator: Allocator, events: std.ArrayList(Event), fn init(self: *MockProvider, app: *App, _: ?[36]u8, _: Config.RunMode) !void { self.* = .{ .events = .{}, .allocator = app.allocator, }; } fn deinit(self: *MockProvider) void { self.events.deinit(self.allocator); } pub fn send(self: *MockProvider, event: Event) !void { try self.events.append(self.allocator, event); } }; ================================================ FILE: src/test_runner.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const BORDER = "=" ** 80; // use in custom panic handler var current_test: ?[]const u8 = null; pub var v8_peak_memory: usize = 0; pub var tracking_allocator: Allocator = undefined; var RUNNER: *Runner = undefined; pub fn main() !void { var mem: [8192]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&mem); var gpa: std.heap.DebugAllocator(.{}) = .init; var arena = std.heap.ArenaAllocator.init(gpa.allocator()); defer arena.deinit(); var ta = TrackingAllocator.init(gpa.allocator()); tracking_allocator = ta.allocator(); const allocator = fba.allocator(); const env = Env.init(allocator); var runner = Runner.init(allocator, arena.allocator(), &ta, env); RUNNER = &runner; try runner.run(); } const Runner = struct { env: Env, allocator: Allocator, ta: *TrackingAllocator, // per-test arena, used for collecting substests arena: Allocator, subtests: std.ArrayList([]const u8), fn init(allocator: Allocator, arena: Allocator, ta: *TrackingAllocator, env: Env) Runner { return .{ .ta = ta, .env = env, .arena = arena, .subtests = .empty, .allocator = allocator, }; } pub fn run(self: *Runner) !void { var slowest = SlowTracker.init(self.allocator, 5); defer slowest.deinit(); var pass: usize = 0; var fail: usize = 0; var skip: usize = 0; var leak: usize = 0; // track all tests duration, excluding setup and teardown. var ns_duration: u64 = 0; Printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line for (builtin.test_functions) |t| { if (isSetup(t)) { t.func() catch |err| { Printer.status(.fail, "\nsetup \"{s}\" failed: {}\n", .{ t.name, err }); return err; }; } } // If we have a subfilter, Document#query_selector_all // Then we have a special check to make sure _some_ test was run. This const webapi_html_test_mode = self.env.filter == null and self.env.subfilter != null; for (builtin.test_functions) |t| { if (isSetup(t) or isTeardown(t)) { continue; } var status = Status.pass; slowest.startTiming(); const is_unnamed_test = isUnnamed(t); if (!is_unnamed_test) { if (self.env.filter) |f| { if (std.mem.indexOf(u8, t.name, f) == null) { continue; } } else if (webapi_html_test_mode) { // allow filtering by subfilter only, assumes subfilters // only exists for "WebApi: " tests (which is true for now). if (std.mem.indexOf(u8, t.name, "WebApi: ") == null) { continue; } } } const friendly_name = blk: { const name = t.name; var it = std.mem.splitScalar(u8, name, '.'); while (it.next()) |value| { if (std.mem.eql(u8, value, "test")) { const rest = it.rest(); break :blk if (rest.len > 0) rest else name; } } break :blk name; }; defer { self.subtests = .{}; const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(self.arena.ptr)); _ = arena.reset(.{ .retain_with_limit = 2048 }); } current_test = friendly_name; std.testing.allocator_instance = .{}; const result = t.func(); current_test = null; if (webapi_html_test_mode and self.subtests.items.len == 0) { continue; } const ns_taken = slowest.endTiming(friendly_name, is_unnamed_test); ns_duration += ns_taken; if (std.testing.allocator_instance.deinit() == .leak) { leak += 1; Printer.status(.fail, "\n{s}\n\"{s}\" - Memory Leak\n{s}\n", .{ BORDER, friendly_name, BORDER }); } if (result) |_| { if (!is_unnamed_test) { pass += 1; } } else |err| switch (err) { error.SkipZigTest => { skip += 1; status = .skip; }, else => { status = .fail; fail += 1; Printer.status(.fail, "\n{s}\n\"{s}\" - {s}\n", .{ BORDER, friendly_name, @errorName(err) }); if (self.subtests.getLastOrNull()) |st| { Printer.status(.fail, " {s}\n", .{st}); } Printer.status(.fail, BORDER ++ "\n", .{}); if (@errorReturnTrace()) |trace| { std.debug.dumpStackTrace(trace.*); } if (self.env.fail_first) { break; } }, } if (!is_unnamed_test) { if (self.env.verbose) { const ms = @as(f64, @floatFromInt(ns_taken)) / 1_000_000.0; Printer.status(status, "{s} ({d:.2}ms)\n", .{ friendly_name, ms }); for (self.subtests.items) |st| { Printer.status(status, " - {s} \n", .{st}); } } else { Printer.status(status, ".", .{}); } } } for (builtin.test_functions) |t| { if (isTeardown(t)) { t.func() catch |err| { Printer.status(.fail, "\nteardown \"{s}\" failed: {}\n", .{ t.name, err }); return err; }; } } const total_tests = pass + fail; const status = if (total_tests > 0 and fail == 0) Status.pass else Status.fail; Printer.status(status, "\n{d} of {d} test{s} passed\n", .{ pass, total_tests, if (total_tests != 1) "s" else "" }); if (skip > 0) { Printer.status(.skip, "{d} test{s} skipped\n", .{ skip, if (skip != 1) "s" else "" }); } if (leak > 0) { Printer.status(.fail, "{d} test{s} leaked\n", .{ leak, if (leak != 1) "s" else "" }); } Printer.fmt("\n", .{}); try slowest.display(); Printer.fmt("\n", .{}); // stats if (self.env.metrics) { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); const stats = self.ta.stats(); try std.json.Stringify.value(&.{ .{ .name = "browser", .bench = .{ .duration = ns_duration, .alloc_nb = stats.allocation_count, .realloc_nb = stats.reallocation_count, .alloc_size = stats.allocated_bytes, } }, .{ .name = "v8", .bench = .{ .duration = ns_duration, .alloc_nb = 0, .realloc_nb = 0, .alloc_size = v8_peak_memory, } }, }, .{ .whitespace = .indent_2 }, &writer.interface); } std.posix.exit(if (fail == 0) 0 else 1); } }; pub fn shouldRun(name: []const u8) bool { const sf = RUNNER.env.subfilter orelse return true; return std.mem.indexOf(u8, name, sf) != null; } pub fn subtest(name: []const u8) !void { try RUNNER.subtests.append(RUNNER.arena, try RUNNER.arena.dupe(u8, name)); } const Printer = struct { fn fmt(comptime format: []const u8, args: anytype) void { std.debug.print(format, args); } fn status(s: Status, comptime format: []const u8, args: anytype) void { switch (s) { .pass => std.debug.print("\x1b[32m", .{}), .fail => std.debug.print("\x1b[31m", .{}), .skip => std.debug.print("\x1b[33m", .{}), else => {}, } std.debug.print(format ++ "\x1b[0m", args); } }; const Status = enum { pass, fail, skip, text, }; const SlowTracker = struct { const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming); max: usize, slowest: SlowestQueue, timer: std.time.Timer, fn init(allocator: Allocator, count: u32) SlowTracker { const timer = std.time.Timer.start() catch @panic("failed to start timer"); var slowest = SlowestQueue.init(allocator, {}); slowest.ensureTotalCapacity(count) catch @panic("OOM"); return .{ .max = count, .timer = timer, .slowest = slowest, }; } const TestInfo = struct { ns: u64, name: []const u8, }; fn deinit(self: SlowTracker) void { self.slowest.deinit(); } fn startTiming(self: *SlowTracker) void { self.timer.reset(); } fn endTiming(self: *SlowTracker, test_name: []const u8, is_unnamed_test: bool) u64 { var timer = self.timer; const ns = timer.lap(); if (is_unnamed_test) { return ns; } var slowest = &self.slowest; if (slowest.count() < self.max) { // Capacity is fixed to the # of slow tests we want to track // If we've tracked fewer tests than this capacity, than always add slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); return ns; } { // Optimization to avoid shifting the dequeue for the common case // where the test isn't one of our slowest. const fastest_of_the_slow = slowest.peekMin() orelse unreachable; if (fastest_of_the_slow.ns > ns) { // the test was faster than our fastest slow test, don't add return ns; } } // the previous fastest of our slow tests, has been pushed off. _ = slowest.removeMin(); slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); return ns; } fn display(self: *SlowTracker) !void { var slowest = self.slowest; const count = slowest.count(); Printer.fmt("Slowest {d} test{s}: \n", .{ count, if (count != 1) "s" else "" }); while (slowest.removeMinOrNull()) |info| { const ms = @as(f64, @floatFromInt(info.ns)) / 1_000_000.0; Printer.fmt(" {d:.2}ms\t{s}\n", .{ ms, info.name }); } } fn compareTiming(context: void, a: TestInfo, b: TestInfo) std.math.Order { _ = context; return std.math.order(a.ns, b.ns); } }; const Env = struct { verbose: bool, fail_first: bool, filter: ?[]const u8, subfilter: ?[]const u8, metrics: bool, fn init(allocator: Allocator) Env { const full_filter = readEnv(allocator, "TEST_FILTER"); const filter, const subfilter = parseFilter(full_filter); return .{ .verbose = readEnvBool(allocator, "TEST_VERBOSE", true), .fail_first = readEnvBool(allocator, "TEST_FAIL_FIRST", false), .filter = filter, .subfilter = subfilter, .metrics = readEnvBool(allocator, "METRICS", false), }; } fn deinit(self: Env, allocator: Allocator) void { if (self.filter) |f| { allocator.free(f); } } fn readEnv(allocator: Allocator, key: []const u8) ?[]const u8 { const v = std.process.getEnvVarOwned(allocator, key) catch |err| { if (err == error.EnvironmentVariableNotFound) { return null; } std.log.warn("failed to get env var {s} due to err {}", .{ key, err }); return null; }; return v; } fn readEnvBool(allocator: Allocator, key: []const u8, deflt: bool) bool { const value = readEnv(allocator, key) orelse return deflt; defer allocator.free(value); return std.ascii.eqlIgnoreCase(value, "true"); } fn parseFilter(full_filter: ?[]const u8) struct { ?[]const u8, ?[]const u8 } { const ff = full_filter orelse return .{ null, null }; if (ff.len == 0) return .{ null, null }; const split = std.mem.indexOfScalarPos(u8, ff, 0, '#') orelse { return .{ ff, null }; }; const filter = std.mem.trim(u8, ff[0..split], " "); return .{ if (filter.len == 0) null else filter, std.mem.trim(u8, ff[split + 1 ..], " "), }; } }; pub const panic = std.debug.FullPanic(struct { pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn { if (current_test) |ct| { std.debug.print("\x1b[31m{s}\npanic running \"{s}\"\n", .{ BORDER, ct }); if (RUNNER.subtests.getLastOrNull()) |st| { std.debug.print(" {s}\n", .{st}); } std.debug.print("\x1b[0m{s}\n", .{BORDER}); } std.debug.defaultPanic(msg, first_trace_addr); } }.panicFn); fn isUnnamed(t: std.builtin.TestFn) bool { const marker = ".test_"; const test_name = t.name; const index = std.mem.indexOf(u8, test_name, marker) orelse return false; _ = std.fmt.parseInt(u32, test_name[index + marker.len ..], 10) catch return false; return true; } fn isSetup(t: std.builtin.TestFn) bool { return std.mem.endsWith(u8, t.name, "tests:beforeAll"); } fn isTeardown(t: std.builtin.TestFn) bool { return std.mem.endsWith(u8, t.name, "tests:afterAll"); } pub const TrackingAllocator = struct { parent_allocator: Allocator, free_count: usize = 0, allocated_bytes: usize = 0, allocation_count: usize = 0, reallocation_count: usize = 0, mutex: std.Thread.Mutex = .{}, const Stats = struct { allocated_bytes: usize, allocation_count: usize, reallocation_count: usize, }; fn init(parent_allocator: Allocator) TrackingAllocator { return .{ .parent_allocator = parent_allocator, }; } pub fn stats(self: *const TrackingAllocator) Stats { return .{ .allocated_bytes = self.allocated_bytes, .allocation_count = self.allocation_count, .reallocation_count = self.reallocation_count, }; } pub fn allocator(self: *TrackingAllocator) Allocator { return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .free = free, .remap = remap, } }; } fn alloc( ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, return_address: usize, ) ?[*]u8 { const self: *TrackingAllocator = @ptrCast(@alignCast(ctx)); self.mutex.lock(); defer self.mutex.unlock(); const result = self.parent_allocator.rawAlloc(len, alignment, return_address); self.allocation_count += 1; self.allocated_bytes += len; return result; } fn resize( ctx: *anyopaque, old_mem: []u8, alignment: std.mem.Alignment, new_len: usize, ra: usize, ) bool { const self: *TrackingAllocator = @ptrCast(@alignCast(ctx)); self.mutex.lock(); defer self.mutex.unlock(); const result = self.parent_allocator.rawResize(old_mem, alignment, new_len, ra); if (result) self.reallocation_count += 1; return result; } fn free( ctx: *anyopaque, old_mem: []u8, alignment: std.mem.Alignment, ra: usize, ) void { const self: *TrackingAllocator = @ptrCast(@alignCast(ctx)); self.mutex.lock(); defer self.mutex.unlock(); self.parent_allocator.rawFree(old_mem, alignment, ra); self.free_count += 1; } fn remap( ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize, ) ?[*]u8 { const self: *TrackingAllocator = @ptrCast(@alignCast(ctx)); self.mutex.lock(); defer self.mutex.unlock(); const result = self.parent_allocator.rawRemap(memory, alignment, new_len, ret_addr); if (result != null) self.reallocation_count += 1; return result; } }; ================================================ FILE: src/testing.zig ================================================ // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier <francis@lightpanda.io> // Pierre Tachoire <pierre@lightpanda.io> // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. const std = @import("std"); const Allocator = std.mem.Allocator; pub const allocator = std.testing.allocator; pub const expectError = std.testing.expectError; pub const expect = std.testing.expect; pub const expectString = std.testing.expectEqualStrings; pub const expectEqualSlices = std.testing.expectEqualSlices; // sometimes it's super useful to have an arena you don't really care about // in a test. Like, you need a mutable string, so you just want to dupe a // string literal. It has nothing to do with the code under test, it's just // infrastructure for the test itself. pub var arena_instance = std.heap.ArenaAllocator.init(std.heap.c_allocator); pub const arena_allocator = arena_instance.allocator(); pub fn reset() void { _ = arena_instance.reset(.retain_capacity); } const App = @import("App.zig"); const js = @import("browser/js/js.zig"); const Config = @import("Config.zig"); const HttpClient = @import("browser/HttpClient.zig"); const Page = @import("browser/Page.zig"); const Browser = @import("browser/Browser.zig"); const Session = @import("browser/Session.zig"); const Notification = @import("Notification.zig"); // Merged std.testing.expectEqual and std.testing.expectString // can be useful when testing fields of an anytype an you don't know // exactly how to assert equality pub fn expectEqual(expected: anytype, actual: anytype) !void { switch (@typeInfo(@TypeOf(actual))) { .array => |arr| if (arr.child == u8) { return std.testing.expectEqualStrings(expected, &actual); }, .pointer => |ptr| { if (ptr.child == u8) { return std.testing.expectEqualStrings(expected, actual); } else if (comptime isStringArray(ptr.child)) { return std.testing.expectEqualStrings(expected, actual); } else if (ptr.child == []u8 or ptr.child == []const u8) { return expectString(expected, actual); } }, .@"struct" => |structType| { inline for (structType.fields) |field| { try expectEqual(@field(expected, field.name), @field(actual, field.name)); } return; }, .optional => { if (@typeInfo(@TypeOf(expected)) == .null) { return std.testing.expectEqual(null, actual); } if (actual) |_actual| { return expectEqual(expected, _actual); } return std.testing.expectEqual(expected, null); }, .@"union" => |union_info| { if (union_info.tag_type == null) { @compileError("Unable to compare untagged union values"); } const Tag = std.meta.Tag(@TypeOf(expected)); const expectedTag = @as(Tag, expected); const actualTag = @as(Tag, actual); try expectEqual(expectedTag, actualTag); inline for (std.meta.fields(@TypeOf(actual))) |fld| { if (std.mem.eql(u8, fld.name, @tagName(actualTag))) { try expectEqual(@field(expected, fld.name), @field(actual, fld.name)); return; } } unreachable; }, else => {}, } return std.testing.expectEqual(expected, actual); } pub fn expectDelta(expected: anytype, actual: anytype, delta: anytype) !void { if (@typeInfo(@TypeOf(expected)) == .null) { return std.testing.expectEqual(null, actual); } switch (@typeInfo(@TypeOf(actual))) { .optional => { if (actual) |value| { return expectDelta(expected, value, delta); } return std.testing.expectEqual(null, expected); }, else => {}, } switch (@typeInfo(@TypeOf(expected))) { .optional => { if (expected) |value| { return expectDelta(value, actual, delta); } return std.testing.expectEqual(null, actual); }, else => {}, } var diff = expected - actual; if (diff < 0) { diff = -diff; } if (diff <= delta) { return; } print("Expected {} to be within {} of {}. Actual diff: {}", .{ expected, delta, actual, diff }); return error.NotWithinDelta; } fn isStringArray(comptime T: type) bool { if (!is(.array)(T) and !isPtrTo(.array)(T)) { return false; } return std.meta.Elem(T) == u8; } pub const TraitFn = fn (type) bool; pub fn is(comptime id: std.builtin.TypeId) TraitFn { const Closure = struct { pub fn trait(comptime T: type) bool { return id == @typeInfo(T); } }; return Closure.trait; } pub fn isPtrTo(comptime id: std.builtin.TypeId) TraitFn { const Closure = struct { pub fn trait(comptime T: type) bool { if (!comptime isSingleItemPtr(T)) return false; return id == @typeInfo(std.meta.Child(T)); } }; return Closure.trait; } pub fn isSingleItemPtr(comptime T: type) bool { if (comptime is(.pointer)(T)) { return @typeInfo(T).pointer.size == .one; } return false; } pub fn print(comptime fmt: []const u8, args: anytype) void { if (@inComptime()) { @compileError(std.fmt.comptimePrint(fmt, args)); } else { std.debug.print(fmt, args); } } const String = @import("string.zig").String; pub fn newString(str: []const u8) String { return String.init(arena_allocator, str, .{}) catch unreachable; } pub const Random = struct { var instance: ?std.Random.DefaultPrng = null; pub fn fill(buf: []u8) void { var r = random(); r.bytes(buf); } pub fn fillAtLeast(buf: []u8, min: usize) []u8 { var r = random(); const l = r.intRangeAtMost(usize, min, buf.len); r.bytes(buf[0..l]); return buf; } pub fn intRange(comptime T: type, min: T, max: T) T { var r = random(); return r.intRangeAtMost(T, min, max); } pub fn random() std.Random { if (instance == null) { var seed: u64 = undefined; std.posix.getrandom(std.mem.asBytes(&seed)) catch unreachable; instance = std.Random.DefaultPrng.init(seed); // instance = std.Random.DefaultPrng.init(0); } return instance.?.random(); } }; pub fn expectJson(a: anytype, b: anytype) !void { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const aa = arena.allocator(); const a_value = try convertToJson(aa, a); const b_value = try convertToJson(aa, b); errdefer { const a_json = std.json.Stringify.valueAlloc(aa, a_value, .{ .whitespace = .indent_2 }) catch unreachable; const b_json = std.json.Stringify.valueAlloc(aa, b_value, .{ .whitespace = .indent_2 }) catch unreachable; std.debug.print("== Expected ==\n{s}\n\n== Actual ==\n{s}", .{ a_json, b_json }); } try expectJsonValue(a_value, b_value); } pub fn isEqualJson(a: anytype, b: anytype) !bool { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const aa = arena.allocator(); const a_value = try convertToJson(aa, a); const b_value = try convertToJson(aa, b); return isJsonValue(a_value, b_value); } fn convertToJson(arena: Allocator, value: anytype) !std.json.Value { const T = @TypeOf(value); if (T == std.json.Value) { return value; } var str: []const u8 = undefined; if (T == []u8 or T == []const u8 or comptime isStringArray(T)) { str = value; } else { str = try std.json.Stringify.valueAlloc(arena, value, .{}); } return std.json.parseFromSliceLeaky(std.json.Value, arena, str, .{}); } fn expectJsonValue(a: std.json.Value, b: std.json.Value) !void { try expectEqual(@tagName(a), @tagName(b)); // at this point, we know that if a is an int, b must also be an int switch (a) { .null => return, .bool => try expectEqual(a.bool, b.bool), .integer => try expectEqual(a.integer, b.integer), .float => try expectEqual(a.float, b.float), .number_string => try expectEqual(a.number_string, b.number_string), .string => try expectEqual(a.string, b.string), .array => { const a_len = a.array.items.len; const b_len = b.array.items.len; try expectEqual(a_len, b_len); for (a.array.items, b.array.items) |a_item, b_item| { try expectJsonValue(a_item, b_item); } }, .object => { var it = a.object.iterator(); while (it.next()) |entry| { const key = entry.key_ptr.*; if (b.object.get(key)) |b_item| { try expectJsonValue(entry.value_ptr.*, b_item); } else { return error.MissingKey; } } }, } } fn isJsonValue(a: std.json.Value, b: std.json.Value) bool { if (std.mem.eql(u8, @tagName(a), @tagName(b)) == false) { return false; } // at this point, we know that if a is an int, b must also be an int switch (a) { .null => return true, .bool => return a.bool == b.bool, .integer => return a.integer == b.integer, .float => return a.float == b.float, .number_string => return std.mem.eql(u8, a.number_string, b.number_string), .string => return std.mem.eql(u8, a.string, b.string), .array => { const a_len = a.array.items.len; const b_len = b.array.items.len; if (a_len != b_len) { return false; } for (a.array.items, b.array.items) |a_item, b_item| { if (isJsonValue(a_item, b_item) == false) { return false; } } return true; }, .object => { var it = a.object.iterator(); while (it.next()) |entry| { const key = entry.key_ptr.*; if (b.object.get(key)) |b_item| { if (isJsonValue(entry.value_ptr.*, b_item) == false) { return false; } } else { return false; } } return true; }, } } pub var test_app: *App = undefined; pub var test_http: *HttpClient = undefined; pub var test_browser: Browser = undefined; pub var test_notification: *Notification = undefined; pub var test_session: *Session = undefined; const WEB_API_TEST_ROOT = "src/browser/tests/"; const HtmlRunnerOpts = struct {}; pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { _ = opts; defer reset(); const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path }); const stat = std.fs.cwd().statFile(root) catch |err| { std.debug.print("Failed to stat file: '{s}'", .{root}); return err; }; switch (stat.kind) { .file => { if (@import("root").shouldRun(std.fs.path.basename(root)) == false) { return; } try @import("root").subtest(root); try runWebApiTest(root); }, .directory => { var dir = try std.fs.cwd().openDir(root, .{ .iterate = true, .no_follow = true, .access_sub_paths = false, }); defer dir.close(); var it = dir.iterateAssumeFirstIteration(); while (try it.next()) |entry| { if (entry.kind != .file) { continue; } if (!std.mem.endsWith(u8, entry.name, ".html")) { continue; } if (@import("root").shouldRun(entry.name) == false) { continue; } const full_path = try std.fs.path.joinZ(arena_allocator, &.{ root, entry.name }); try @import("root").subtest(entry.name); try runWebApiTest(full_path); } }, else => |kind| { std.debug.print("Unknown file type: {s} for {s}\n", .{ @tagName(kind), root }); return error.InvalidTestPath; }, } } fn runWebApiTest(test_file: [:0]const u8) !void { const page = try test_session.createPage(); defer test_session.removePage(); const url = try std.fmt.allocPrintSentinel( arena_allocator, "http://127.0.0.1:9582/{s}", .{test_file}, 0, ); var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); var try_catch: js.TryCatch = undefined; try_catch.init(&ls.local); defer try_catch.deinit(); try page.navigate(url, .{}); _ = test_session.wait(2000); test_browser.runMicrotasks(); ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| { const caught = try_catch.caughtOrError(arena_allocator, err); std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught }); return err; }; } // Used by a few CDP tests - wouldn't be sad to see this go. pub fn pageTest(comptime test_file: []const u8) !*Page { const page = try test_session.createPage(); errdefer test_session.removePage(); const url = try std.fmt.allocPrintSentinel( arena_allocator, "http://127.0.0.1:9582/{s}{s}", .{ WEB_API_TEST_ROOT, test_file }, 0, ); try page.navigate(url, .{}); _ = test_session.wait(2000); return page; } test { std.testing.refAllDecls(@This()); } const log = @import("log.zig"); const TestHTTPServer = @import("TestHTTPServer.zig"); const Server = @import("Server.zig"); var test_cdp_server: ?*Server = null; var test_cdp_server_thread: ?std.Thread = null; var test_http_server: ?TestHTTPServer = null; var test_http_server_thread: ?std.Thread = null; var test_config: Config = undefined; test "tests:beforeAll" { log.opts.level = .warn; log.opts.format = .pretty; const test_allocator = @import("root").tracking_allocator; test_config = try Config.init(test_allocator, "test", .{ .serve = .{ .common = .{ .tls_verify_host = false, .user_agent_suffix = "internal-tester", }, } }); test_app = try App.init(test_allocator, &test_config); errdefer test_app.deinit(); test_http = try HttpClient.init(test_allocator, &test_app.network); errdefer test_http.deinit(); test_browser = try Browser.init(test_app, .{ .http_client = test_http }); errdefer test_browser.deinit(); // Create notification for testing test_notification = try Notification.init(test_app.allocator); errdefer test_notification.deinit(); test_session = try test_browser.newSession(test_notification); var wg: std.Thread.WaitGroup = .{}; wg.startMany(2); test_cdp_server_thread = try std.Thread.spawn(.{}, serveCDP, .{&wg}); test_http_server = TestHTTPServer.init(testHTTPHandler); test_http_server_thread = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &test_http_server.?, &wg }); // need to wait for the servers to be listening, else tests will fail because // they aren't able to connect. wg.wait(); } test "tests:afterAll" { test_app.network.stop(); if (test_cdp_server_thread) |thread| { thread.join(); } if (test_cdp_server) |server| { server.deinit(); } if (test_http_server) |*server| { server.stop(); } if (test_http_server_thread) |thread| { thread.join(); } if (test_http_server) |*server| { server.deinit(); } @import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size; test_notification.deinit(); test_browser.deinit(); test_http.deinit(); test_app.deinit(); test_config.deinit(@import("root").tracking_allocator); } fn serveCDP(wg: *std.Thread.WaitGroup) !void { const address = try std.net.Address.parseIp("127.0.0.1", 9583); test_cdp_server = Server.init(test_app, address) catch |err| { std.debug.print("CDP server error: {}", .{err}); return err; }; wg.finish(); test_app.network.run(); } fn testHTTPHandler(req: *std.http.Server.Request) !void { const path = req.head.target; if (std.mem.eql(u8, path, "/xhr")) { return req.respond("1234567890" ** 10, .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, }, }); } if (std.mem.eql(u8, path, "/xhr_empty")) { return req.respond("", .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, }, }); } if (std.mem.eql(u8, path, "/xhr/json")) { return req.respond("{\"over\":\"9000!!!\",\"updated_at\":1765867200000}", .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "application/json" }, }, }); } if (std.mem.eql(u8, path, "/xhr/redirect")) { return req.respond("", .{ .status = .found, .extra_headers = &.{ .{ .name = "Location", .value = "http://127.0.0.1:9582/xhr" }, }, }); } if (std.mem.eql(u8, path, "/xhr/404")) { return req.respond("Not Found", .{ .status = .not_found, .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/plain" }, }, }); } if (std.mem.eql(u8, path, "/xhr/500")) { return req.respond("Internal Server Error", .{ .status = .internal_server_error, .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/plain" }, }, }); } if (std.mem.eql(u8, path, "/xhr/binary")) { return req.respond(&.{ 0, 0, 1, 2, 0, 0, 9 }, .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "application/octet-stream" }, }, }); } if (std.mem.startsWith(u8, path, "/src/browser/tests/")) { // strip off leading / so that it's relative to CWD return TestHTTPServer.sendFile(req, path[1..]); } std.debug.print("TestHTTPServer was asked to serve an unknown file: {s}\n", .{path}); unreachable; } /// LogFilter provides a scoped way to suppress specific log categories during tests. /// This is useful for tests that trigger expected errors or warnings. pub const LogFilter = struct { old_filter: []const log.Scope, /// Sets the log filter to suppress the specified scope(s). /// Returns a LogFilter that should be deinitialized to restore previous filters. pub fn init(comptime scopes: []const log.Scope) LogFilter { comptime std.debug.assert(@TypeOf(scopes) == []const log.Scope); const old_filter = log.opts.filter_scopes; log.opts.filter_scopes = scopes; return .{ .old_filter = old_filter }; } /// Restores the log filters to their previous state. pub fn deinit(self: LogFilter) void { log.opts.filter_scopes = self.old_filter; } };